Compare commits

..

11 Commits

Author SHA1 Message Date
Haileyesus
3c07fef6d3 fix(session-synchronizer): improve session name extraction for Claude and Codex 2026-05-04 12:45:08 +03:00
Haileyesus
e6be3528cc fix(cli): resolve executable path for Claude CLI on Windows 2026-05-04 11:26:01 +03:00
Haileyesus
c50351ee59 fix(chat): preserve continuity while session ids settle
New conversations were crossing a short but important consistency gap.
The route could already point at a newly created session id while the
projects payload had not refreshed yet, and realtime/optimistic messages
could still be keyed under a provisional id. In that window the UI could
stop reading the active session store, briefly render the conversation as
missing, and then repopulate it a moment later.

That same gap also made duplication more likely. Optimistic local user
messages could survive long enough to appear beside the persisted copy,
and finalized assistant streaming rows could sit directly next to the
server-backed assistant message with the same content before realtime state
was cleared. The result was a chat view that felt unstable exactly when a
new session was being created.

This commit makes session-id reconciliation a first-class part of the chat
flow instead of assuming every layer will agree immediately. The session
store now understands canonical session aliases and can migrate one
conversation from a provisional id to the real id without dropping its
in-memory state. The route navigation path can replace the provisional URL
entry instead of stacking it in history, and the project/session selection
logic keeps a synthetic selected session alive long enough for the sidebar
and project payloads to catch up.

The practical goal is to keep one visible conversation throughout the whole
creation lifecycle: no dead window between websocket events and project
refresh, no stale provisional URL after the real id is known, and no extra
optimistic/local bubbles when server history catches up.
2026-05-04 11:18:12 +03:00
Haileyesus
9063918c1f fix: reset-state-on-new-session-click 2026-05-04 11:18:12 +03:00
simosmik
392c73b693 fix: add clarification on auto mode 2026-04-30 15:28:00 +00:00
viper151
5e7c4c5f8c chore(release): v1.31.5 2026-04-30 14:12:48 +00:00
simosmik
3f71d4932b feat: add auto mode to claude code 2026-04-30 14:09:51 +00:00
viper151
80561ee9e9 chore(release): v1.31.4 2026-04-30 12:47:35 +00:00
simosmik
658421c1c4 fix: bump codex sdk to latest version 2026-04-30 12:45:30 +00:00
viper151
881465aa71 chore(release): v1.31.3 2026-04-30 11:50:31 +00:00
Simos Mikelatos
9f2afebc66 Create command palette and add new features for search and actions (#728)
* refactor(ui): replace in-repo Command primitive with cmdk wrapper

* feat(command-palette): add global Cmd+K palette with v1 actions

* feat(command-palette): add session, file, and commit search sources

* refactor: add provider names to model constants

* feat(command-palette): add settings, navigation, message search, and ⌘K hints

* feat(command-palette): add git fetch/pull/push and branch switch actions

* refactor(command-palette): consolidate fetch source hooks behind useApiSource

* refactor(command-palette): extract useCommandKey and SETTINGS_MAIN_TABS metadata

* refactor(command-palette): extract groups into declarative registry

* refactor(command-palette): wire openFile through PaletteOpsContext

* refactor: migrate openSettings and refreshProjects from window.* to PaletteOpsContext

* refactor(command-palette): inline groups and delete registry indirection

* refactor(command-palette): return items array directly from source hooks

* refactor(palette-ops): flatten Handle wrapper into ref-based registry

* refactor: inline useCommandKey as MOD_KEY constant in two call sites

* feat: introduce pages and fix bug on branch switching

* fix: small labels

* fix: coderabbit issues

* fix: coderabbit comments

* Update src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-30 14:48:48 +03:00
55 changed files with 2528 additions and 527 deletions

View File

@@ -3,6 +3,20 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
### New Features
* add auto mode to claude code ([3f71d49](https://github.com/siteboon/claudecodeui/commit/3f71d4932b05dfedcdf816e2a3d7d0cd69c4f566))
## [1.31.4](https://github.com/siteboon/claudecodeui/compare/v1.31.3...v1.31.4) (2026-04-30)
### Bug Fixes
* bump codex sdk to latest version ([658421c](https://github.com/siteboon/claudecodeui/commit/658421c1c44ec4eb58b69ec7b1844a9fba11a3f3))
## [1.31.3](https://github.com/siteboon/claudecodeui/compare/v1.31.2...v1.31.3) (2026-04-30)
## [1.31.2](https://github.com/siteboon/claudecodeui/compare/v1.31.0...v1.31.2) (2026-04-30)
### Bug Fixes

View File

@@ -157,7 +157,7 @@ export default tseslint.config(
},
{
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly
mode: "file",
},
{

667
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.2",
"version": "1.31.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.2",
"version": "1.31.5",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
@@ -21,7 +21,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@iarna/toml": "^2.2.5",
"@octokit/rest": "^22.0.0",
"@openai/codex-sdk": "^0.101.0",
"@openai/codex-sdk": "^0.125.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -36,6 +36,7 @@
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",
@@ -3306,9 +3307,9 @@
}
},
"node_modules/@openai/codex": {
"version": "0.101.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0.tgz",
"integrity": "sha512-H874q5K5I3chrT588BaddMr7GNvRYypc8C1MKWytNUF2PgxWMko2g/2DgKbt5OdajZKMsWdbsPywu34KQGf5Qw==",
"version": "0.125.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0.tgz",
"integrity": "sha512-GiE9wlgL95u/5BRirY5d3EaRLU1tu7Y1R09R8lCHHVmcQdSmhS809FdPDWH3gIYHS7ZriAPqXwJ3aLA0WKl40Q==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -3317,19 +3318,19 @@
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.101.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.101.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.101.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.101.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.101.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.101.0-win32-x64"
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.125.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.125.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.125.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.125.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.125.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.125.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.101.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-darwin-arm64.tgz",
"integrity": "sha512-unk4rTRQQ9o0w2Upu35IsJHpoZHJ+tU/myn6LNhUjcP9FrjLnEcAQJ6WIMtdTYVPja1PGhFSO0DNxV79GMvehw==",
"version": "0.125.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-darwin-arm64.tgz",
"integrity": "sha512-Gn2fHiSO0XgyHp1OSd5DWUTm66Bv9UEuipW5pVEj1E+hWZCOrdqnYttllKFWtRGj5yiKefNX3JIxONgh/ZwlOQ==",
"cpu": [
"arm64"
],
@@ -3344,9 +3345,9 @@
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.101.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-darwin-x64.tgz",
"integrity": "sha512-+KFi1IapCQGd3vLQp2lI4xI3hu2QffDZYt7Fhfw6NxEFOKhHnTamRtQ5yI8jYQcYF+pQfYF2fyiuXLM1lITLQw==",
"version": "0.125.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-darwin-x64.tgz",
"integrity": "sha512-TZ5Lek2X/UXTI9LXFxzarvQaJeuTrqVh4POc7soO/8RclVnCxADnCf15sivxLd5eiFW4t0myGoeVoM4lciRiRg==",
"cpu": [
"x64"
],
@@ -3361,9 +3362,9 @@
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.101.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-linux-arm64.tgz",
"integrity": "sha512-RkDnQeq7M6ZBtD+8i+I5ewjjOf02BcJq6r1kN4RBewfAQBsz6B73Ns3OrI2bHVRsuPtAf8Cf1S4xg/eFZT2Omg==",
"version": "0.125.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-linux-arm64.tgz",
"integrity": "sha512-pPnJoJD6rZ2Iin0zNt/up36bO2/EOp2B+1/rPHu/lSq3PJbT3Fmnfut2kJy5LylXb7bGA2XQbtqOogZzIbnlkA==",
"cpu": [
"arm64"
],
@@ -3378,9 +3379,9 @@
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.101.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-linux-x64.tgz",
"integrity": "sha512-SJeEdQ4ReEU3nvtceZ1uY3me6oWoB3djr3GnZmAUCEUuYEWD1kRGprAyJB1N0B+8zhSv0SU2e9sX5t3aCV4AwQ==",
"version": "0.125.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-linux-x64.tgz",
"integrity": "sha512-K2NTTEeBpz/G+N2x17UGWfauRt3So+ir4f+U/60l5PPnYEJB/w3YZrlXo2G9og8Dm9BqtoBAjoPV74sRv9tWWQ==",
"cpu": [
"x64"
],
@@ -3394,12 +3395,12 @@
}
},
"node_modules/@openai/codex-sdk": {
"version": "0.101.0",
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.101.0.tgz",
"integrity": "sha512-Lrar2pDvGUX64itSbMNKuNBzxh72UwKokY4TPuXJRURwGX0qyDi80n7DiVivC40BwFsQWNs6behSo/9Mr6PoLw==",
"version": "0.125.0",
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.125.0.tgz",
"integrity": "sha512-1xCIHdSbQVF880nJ2aVWdPIsWZbSpKODwuP9y/gvtChDYhYfYEW0DKp2H8ZlctkzIjlzS/WzYmP6ZZPHIvs2Dg==",
"license": "Apache-2.0",
"dependencies": {
"@openai/codex": "0.101.0"
"@openai/codex": "0.125.0"
},
"engines": {
"node": ">=18"
@@ -3407,9 +3408,9 @@
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.101.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-win32-arm64.tgz",
"integrity": "sha512-WQ8QsychjHyvlr+vCSTMbd2/yrBIZre5tRuM79eZi973BJz0CSEiFsNSGg5fvpnJuiHHawZ/8HWeir7nlatamQ==",
"version": "0.125.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-win32-arm64.tgz",
"integrity": "sha512-zxoUakw9oIHIFrAyk400XkkLBJFA6nOym0NDq6sQ/jhdcYraKqNSRCII2nsBwZHk+/4zgUvuk52iuutgysY/rQ==",
"cpu": [
"arm64"
],
@@ -3424,9 +3425,9 @@
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.101.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-win32-x64.tgz",
"integrity": "sha512-H+7h9x0fYrJRUZZHCA62Dzb/CS5Scl1sUw1aamfmHJzzorX+uTFOgGsibzqFpHTd6nRM4q8//fCdSxe5wUpOQQ==",
"version": "0.125.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.125.0-win32-x64.tgz",
"integrity": "sha512-ofpOK+OWH5QFuUZ9pTM0d/PcXUXiIP5z5DpRcE9MlucJoyOl4Zy4Nu3NcuHF4YzCkZMQb6x3j0tjDEPHKqNQzw==",
"cpu": [
"x64"
],
@@ -3463,6 +3464,447 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@release-it/conventional-changelog": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.5.tgz",
@@ -4111,7 +4553,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -5084,6 +5526,18 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -6186,6 +6640,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
@@ -6958,6 +7428,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8674,6 +9150,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -14063,6 +14548,53 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
@@ -14095,6 +14627,28 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
@@ -17592,6 +18146,49 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.31.2",
"version": "1.31.5",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -76,7 +76,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@iarna/toml": "^2.2.5",
"@octokit/rest": "^22.0.0",
"@openai/codex-sdk": "^0.101.0",
"@openai/codex-sdk": "^0.125.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -91,6 +91,7 @@
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"express": "^4.18.2",

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
import {
createNotificationEvent,
notifyRunFailed,
@@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) {
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
sdkOptions.env = { ...process.env };
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
// this fallback ensures users who installed via the official installer still work
// even when npm prune --production has removed those optional deps.
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
// which does not reliably follow npm's shell wrappers like cross-spawn does.
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// Map working directory
if (cwd) {
@@ -527,6 +526,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
}]
};
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
// at the permission-mode step and skips this callback, so interactive tools
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
// auto-approves them and the model acts on a generated answer. Move these
// tools to a PreToolUse hook (runs before the mode check) if we need them
// to work in those modes.
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);

View File

@@ -4,6 +4,7 @@ import path from 'node:path';
import spawn from 'cross-spawn';
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
@@ -20,13 +21,13 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks whether the Claude Code CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**

View File

@@ -1,5 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import {
@@ -91,7 +92,7 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
filePath: string,
nameMap: Map<string, string>
): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => {
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>;
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const projectPath = typeof data.cwd === 'string' ? data.cwd : undefined;
@@ -103,8 +104,68 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer {
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Claude Session'),
};
});
if (!parsed) {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Claude Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'),
};
}
let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId);
}
return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Claude Session'),
};
}
private async extractSessionAiTitleFromEnd(
filePath: string,
sessionId: string
): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]?.trim();
if (!line) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const data = parsed as Record<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined;
const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined;
const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined;
if ((eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim())) {
return aiTitle || lastPrompt;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}
return undefined;
}
}

View File

@@ -1,5 +1,6 @@
import os from 'node:os';
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { sessionsDb } from '@/modules/database/index.js';
import {
@@ -99,7 +100,7 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
filePath: string,
nameMap: Map<string, string>
): Promise<ParsedSession | null> {
return extractFirstValidJsonlData(filePath, (rawData) => {
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
const data = rawData as Record<string, unknown>;
const payload = data.payload as Record<string, unknown> | undefined;
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
@@ -112,8 +113,67 @@ export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
return {
sessionId,
projectPath,
sessionName: normalizeSessionName(nameMap.get(sessionId), 'Untitled Codex Session'),
};
});
if (!parsed) {
return null;
}
const existingSession = sessionsDb.getSessionById(parsed.sessionId);
const existingSessionName = existingSession?.custom_name;
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
return {
...parsed,
sessionName: normalizeSessionName(existingSessionName, 'Untitled Codex Session'),
};
}
let sessionName = nameMap.get(parsed.sessionId);
if (!sessionName) {
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
}
return {
...parsed,
sessionName: normalizeSessionName(sessionName, 'Untitled Codex Session'),
};
}
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
const lines = content.split(/\r?\n/);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]?.trim();
if (!line) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const data = parsed as Record<string, unknown>;
const eventType = typeof data.type === 'string' ? data.type : undefined;
const payload = data.payload as Record<string, unknown> | undefined;
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
const lastAgentMessage = typeof payload?.last_agent_message === 'string'
? payload.last_agent_message
: undefined;
if (eventType === 'event_msg' && payloadType === 'task_complete' && lastAgentMessage?.trim()) {
return lastAgentMessage;
}
}
} catch {
// Ignore missing/unreadable files so sync can continue.
}
return undefined;
}
}

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveClaudeCodeExecutablePath,
type ResolveClaudeCodeExecutablePathDependencies,
} from '@/shared/claude-cli-path.js';
test('resolveClaudeCodeExecutablePath resolves the npm Claude wrapper to its native exe on Windows', () => {
const wrapperDir = 'C:\\nvm4w\\nodejs';
const nativePath = `${wrapperDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
const execFileSync =
(() => `${wrapperDir}\\claude\r\n${wrapperDir}\\claude.cmd\r\n`) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
const readFileSync = (() => '') as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync'];
const resolved = resolveClaudeCodeExecutablePath('claude', {
platform: 'win32',
execFileSync,
existsSync: (candidate) => candidate === nativePath,
readFileSync,
});
assert.equal(resolved, nativePath);
});
test('resolveClaudeCodeExecutablePath keeps an explicit JavaScript launcher path unchanged', () => {
const scriptPath = 'C:\\tools\\claude.js';
const resolved = resolveClaudeCodeExecutablePath(scriptPath, {
platform: 'win32',
});
assert.equal(resolved, scriptPath);
});
test('resolveClaudeCodeExecutablePath falls back to the configured command when PATH lookup fails', () => {
const execFileSync = (() => {
throw new Error('not found');
}) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
const resolved = resolveClaudeCodeExecutablePath('claude', {
platform: 'win32',
execFileSync,
});
assert.equal(resolved, 'claude');
});

View File

@@ -0,0 +1,139 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_CLAUDE_COMMAND = 'claude';
const CLAUDE_SCRIPT_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
const CLAUDE_WRAPPER_SEGMENTS = ['node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'] as const;
export type ResolveClaudeCodeExecutablePathDependencies = {
execFileSync?: typeof execFileSync;
existsSync?: typeof fs.existsSync;
platform?: NodeJS.Platform;
readFileSync?: typeof fs.readFileSync;
};
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path;
}
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function isPathLike(value: string): boolean {
return value.includes('/') || value.includes('\\');
}
function resolveClaudeWrapperBinary(
wrapperPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string | null {
const pathApi = getPathApi(deps.platform);
const directCandidate = pathApi.resolve(pathApi.dirname(wrapperPath), ...CLAUDE_WRAPPER_SEGMENTS);
if (deps.existsSync(directCandidate)) {
return directCandidate;
}
let content: string;
try {
content = deps.readFileSync(wrapperPath, 'utf8');
} catch {
return null;
}
const matches = content.matchAll(/["']([^"'\\r\\n]*claude\.exe)["']/gi);
for (const match of matches) {
const rawTarget = match[1]
.replace(/^\$basedir[\\/]/i, '')
.replace(/^%dp0%[\\/]/i, '')
.replace(/^%~dp0[\\/]/i, '');
const normalizedTarget = rawTarget.replace(/[\\/]/g, pathApi.sep);
const candidate = pathApi.isAbsolute(normalizedTarget)
? normalizedTarget
: pathApi.resolve(pathApi.dirname(wrapperPath), normalizedTarget);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveWindowsClaudeExecutablePath(
configuredPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string {
const pathApi = getPathApi(deps.platform);
const extension = pathApi.extname(configuredPath).toLowerCase();
const explicitPath = isPathLike(configuredPath) || pathApi.isAbsolute(configuredPath);
if (CLAUDE_SCRIPT_EXTENSIONS.has(extension)) {
return configuredPath;
}
if (explicitPath && extension === '.exe') {
return configuredPath;
}
if (explicitPath) {
return resolveClaudeWrapperBinary(configuredPath, deps) ?? configuredPath;
}
try {
const stdout = deps.execFileSync('where.exe', [configuredPath], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const candidates = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (pathApi.extname(candidate).toLowerCase() === '.exe') {
return candidate;
}
}
for (const candidate of candidates) {
const resolved = resolveClaudeWrapperBinary(candidate, deps);
if (resolved) {
return resolved;
}
}
} catch {
return configuredPath;
}
return configuredPath;
}
export function resolveClaudeCodeExecutablePath(
configuredPath: string | undefined = process.env.CLAUDE_CLI_PATH,
dependencies: ResolveClaudeCodeExecutablePathDependencies = {},
): string {
const deps: Required<ResolveClaudeCodeExecutablePathDependencies> = {
execFileSync: dependencies.execFileSync ?? execFileSync,
existsSync: dependencies.existsSync ?? fs.existsSync,
platform: dependencies.platform ?? process.platform,
readFileSync: dependencies.readFileSync ?? fs.readFileSync,
};
const normalizedPath = stripWrappingQuotes(configuredPath || DEFAULT_CLAUDE_COMMAND);
if (deps.platform !== 'win32') {
return normalizedPath;
}
return resolveWindowsClaudeExecutablePath(normalizedPath, deps);
}

View File

@@ -51,7 +51,7 @@ export const CURSOR_MODELS = {
{ value: "grok", label: "Grok" },
],
DEFAULT: "gpt-5-3-codex",
DEFAULT: "gpt-5.3-codex",
};
/**
@@ -94,3 +94,13 @@ export const GEMINI_MODELS = {
DEFAULT: "gemini-3.1-pro-preview",
};
/**
* Ordered provider registry. Display order in selection UIs.
*/
export const PROVIDERS = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS },
];

View File

@@ -1,14 +1,25 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Sidebar from '../sidebar/view/Sidebar';
import MainContent from '../main-content/view/MainContent';
import CommandPalette from '../command-palette/CommandPalette';
import { useWebSocket } from '../../contexts/WebSocketContext';
import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/PaletteOpsContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState';
export default function AppContent() {
return (
<PaletteOpsProvider>
<AppContentInner />
</PaletteOpsProvider>
);
}
function AppContentInner() {
const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common');
@@ -33,6 +44,7 @@ export default function AppContent() {
sidebarOpen,
isLoadingProjects,
externalMessageUpdate,
newSessionTrigger,
setActiveTab,
setSidebarOpen,
setIsInputFocused,
@@ -40,6 +52,7 @@ export default function AppContent() {
openSettings,
refreshProjectsSilently,
sidebarSharedProps,
handleNewSession,
} = useProjectsState({
sessionId,
navigate,
@@ -48,27 +61,10 @@ export default function AppContent() {
activeSessions,
});
useEffect(() => {
// Expose a non-blocking refresh for chat/session flows.
// Full loading refreshes are still available through direct fetchProjects calls.
window.refreshProjects = refreshProjectsSilently;
return () => {
if (window.refreshProjects === refreshProjectsSilently) {
delete window.refreshProjects;
}
};
}, [refreshProjectsSilently]);
useEffect(() => {
window.openSettings = openSettings;
return () => {
if (window.openSettings === openSettings) {
delete window.openSettings;
}
};
}, [openSettings]);
usePaletteOpsRegister({
openSettings,
refreshProjects: refreshProjectsSilently,
});
useEffect(() => {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
@@ -196,12 +192,21 @@ export default function AppContent() {
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}
onNavigateToSession={(targetSessionId: string, options) =>
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
}
onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}
/>
</div>
<CommandPalette
selectedProject={selectedProject}
onStartNewChat={handleNewSession}
onOpenSettings={() => openSettings()}
onShowTab={setActiveTab}
/>
</div>
);
}

View File

@@ -4,6 +4,16 @@ import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../..
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') {
return ['default', 'acceptEdits', 'bypassPermissions'];
}
if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
};
interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null;
}
@@ -34,9 +44,10 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
return;
}
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
setPermissionMode((savedMode as PermissionMode) || 'default');
}, [selectedSession?.id]);
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -84,10 +95,7 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
}, [provider]);
const cyclePermissionMode = useCallback(() => {
const modes: PermissionMode[] =
provider === 'codex'
? ['default', 'acceptEdits', 'bypassPermissions']
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const modes = getPermissionModesForProvider(provider);
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { PendingPermissionRequest } from '../types/types';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
@@ -66,7 +68,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
}
@@ -99,6 +101,7 @@ export function useChatRealtimeHandlers({
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
@@ -271,18 +274,56 @@ export function useChatRealtimeHandlers({
break;
}
// Clear pending session
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
if (pendingSessionId && !currentSessionId && msg.exitCode === 0) {
const actualId = msg.actualSessionId || pendingSessionId;
setCurrentSessionId(actualId);
if (msg.actualSessionId) {
onNavigateToSession?.(actualId);
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
const isVisibleSession =
Boolean(
sid
&& (
sid === activeViewSessionId
|| sid === pendingSessionId
|| pendingViewSessionRef.current?.sessionId === sid
),
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
if (pendingViewSessionRef.current) {
const pendingSession = pendingViewSessionRef.current.sessionId;
if (!pendingSession || pendingSession === sid) {
pendingViewSessionRef.current.sessionId = actualSessionId;
}
}
}
if (completedSuccessfully && pendingSessionId === sid) {
sessionStorage.removeItem('pendingSessionId');
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
// Clear pending session
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
const resolvedSessionId = actualSessionId || pendingSessionId;
setCurrentSessionId(resolvedSessionId);
if (actualSessionId) {
onNavigateToSession?.(resolvedSessionId, { replace: true });
}
sessionStorage.removeItem('pendingSessionId');
if (window.refreshProjects) {
setTimeout(() => window.refreshProjects?.(), 500);
}
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
@@ -365,5 +406,6 @@ export function useChatRealtimeHandlers({
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
paletteOps,
]);
}

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, Provider } from '../types/types';
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
import { normalizedToChatMessages } from './useChatMessages';
const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100;
@@ -22,6 +24,7 @@ interface UseChatSessionStateArgs {
sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: Set<string>;
resetStreamingState: () => void;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
@@ -95,6 +98,7 @@ export function useChatSessionState({
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
resetStreamingState,
pendingViewSessionRef,
@@ -131,15 +135,85 @@ export function useChatSessionState({
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastLoadedSessionKeyRef = useRef<string | null>(null);
/**
* Tracks the last processed value from `useProjectsState.newSessionTrigger`.
*
* The trigger itself is intentionally increment-only and routed via:
* useProjectsState -> AppContent -> MainContent -> ChatInterface -> this hook.
* We compare values to ensure each explicit New Session click runs exactly one
* reset pass in this local chat state domain.
*/
const previousNewSessionTriggerRef = useRef(newSessionTrigger ?? 0);
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
useEffect(() => {
const trigger = newSessionTrigger ?? 0;
if (trigger === previousNewSessionTriggerRef.current) {
return;
}
previousNewSessionTriggerRef.current = trigger;
/**
* Consumer-side reset for explicit New Session intent.
*
* Why this is essential:
* - Chat keeps local state that is not fully derived from `selectedSession`:
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
* pagination/scroll bookkeeping, and pending session IDs in sessionStorage.
* - If the user clicks New Session while already on the same route with no
* selected session, parent state updates can be idempotent and this local
* state would otherwise persist, making the click appear to "do nothing".
*
* What this reset guarantees:
* - A deterministic clean draft state on every New Session click.
* - No dependence on route/tab/session-object identity changes.
* - No coupling to unrelated external update signals.
*/
resetStreamingState();
pendingViewSessionRef.current = null;
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
setCurrentSessionId(null);
setPendingUserMessage(null);
sessionStorage.removeItem('pendingSessionId');
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
setAllMessagesLoaded(false);
allMessagesLoadedRef.current = false;
setIsLoadingAllMessages(false);
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
setSearchTarget(null);
searchScrollActiveRef.current = false;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
pendingInitialScrollRef.current = true;
lastLoadedSessionKeyRef.current = null;
if (loadAllOverlayTimerRef.current) {
clearTimeout(loadAllOverlayTimerRef.current);
loadAllOverlayTimerRef.current = null;
}
if (loadAllFinishedTimerRef.current) {
clearTimeout(loadAllFinishedTimerRef.current);
loadAllFinishedTimerRef.current = null;
}
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
/* ---------------------------------------------------------------- */
/* Derive chatMessages from the store */
/* ---------------------------------------------------------------- */
const activeSessionId = selectedSession?.id || currentSessionId || null;
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
// Tell the store which session we're viewing so it only re-renders for this one
const prevActiveForStoreRef = useRef<string | null>(null);
@@ -148,17 +222,29 @@ export function useChatSessionState({
sessionStore.setActiveSession(activeSessionId);
}
// When a real session ID arrives and we have a pending user message, flush it to the store
const prevActiveSessionRef = useRef<string | null>(null);
if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) {
useEffect(() => {
if (!pendingUserMessage) {
flushedPendingUserMessageRef.current = null;
return;
}
if (!activeSessionId) {
return;
}
if (flushedPendingUserMessageRef.current === pendingUserMessage) {
return;
}
const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized);
}
flushedPendingUserMessageRef.current = pendingUserMessage;
setPendingUserMessage(null);
}
prevActiveSessionRef.current = activeSessionId;
}, [activeSessionId, pendingUserMessage, sessionStore]);
const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : [];

View File

@@ -2,7 +2,7 @@ import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
export interface ChatImage {
data: string;
@@ -91,6 +91,10 @@ export interface Question {
multiSelect?: boolean;
}
export type SessionNavigationOptions = {
replace?: boolean;
};
export interface ChatInterfaceProps {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
@@ -105,7 +109,7 @@ export interface ChatInterfaceProps {
onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (targetSessionId: string) => void;
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean;
@@ -113,6 +117,7 @@ export interface ChatInterfaceProps {
autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
onTaskClick?: (...args: unknown[]) => void;
onShowAllTasks?: (() => void) | null;
}

View File

@@ -43,6 +43,7 @@ function ChatInterface({
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
@@ -123,6 +124,7 @@ function ChatInterface({
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
resetStreamingState,
pendingViewSessionRef,

View File

@@ -325,9 +325,11 @@ export default function ChatComposer({
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
: permissionMode === 'auto'
? 'border-blue-300/60 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-600/40 dark:bg-blue-900/15 dark:text-blue-300 dark:hover:bg-blue-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
@@ -338,14 +340,17 @@ export default function ChatComposer({
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
: permissionMode === 'auto'
? 'bg-blue-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span className="hidden whitespace-nowrap sm:inline">
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'auto' && t('codex.modes.auto')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
@@ -9,6 +9,7 @@ import {
CURSOR_MODELS,
CODEX_MODELS,
GEMINI_MODELS,
PROVIDERS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
@@ -26,6 +27,9 @@ import {
Card,
} from "../../../../shared/view/ui";
const MOD_KEY =
typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
@@ -52,12 +56,11 @@ type ProviderGroup = {
models: { value: string; label: string }[];
};
const PROVIDER_GROUPS: ProviderGroup[] = [
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
{ id: "cursor", name: "Cursor", models: CURSOR_MODELS.OPTIONS },
{ id: "codex", name: "OpenAI", models: CODEX_MODELS.OPTIONS },
{ id: "gemini", name: "Google", models: GEMINI_MODELS.OPTIONS },
];
const PROVIDER_GROUPS: ProviderGroup[] = PROVIDERS.map((p) => ({
id: p.id as LLMProvider,
name: p.name,
models: p.models.OPTIONS,
}));
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
@@ -231,9 +234,14 @@ export default function ProviderSelectionEmptyState({
defaultValue: "No models found.",
})}
</CommandEmpty>
{visibleProviderGroups.map((group) => (
{visibleProviderGroups.map((group, idx) => (
<CommandGroup
key={group.id}
className={
idx > 0
? "border-t border-border/40 [&_[cmdk-group-heading]]:mt-1 [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider"
: "[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider"
}
heading={
<span className="flex items-center gap-1.5">
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
@@ -248,6 +256,7 @@ export default function ProviderSelectionEmptyState({
key={`${group.id}-${model.value}`}
value={`${group.name} ${model.label}`}
onSelect={() => handleModelSelect(group.id, model.value)}
className="ml-4 border-l border-border/40 pl-4"
>
<span className="flex-1 truncate">{model.label}</span>
{isSelected && (
@@ -282,6 +291,18 @@ export default function ProviderSelectionEmptyState({
}
</p>
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<Trans
i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{
kbd: (
<kbd className="inline-flex items-center gap-0.5 rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px]" />
),
}}
/>
</p>
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner

View File

@@ -3,6 +3,7 @@ import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -36,6 +37,7 @@ export default function CodeEditor({
onPopOut = null,
}: CodeEditorProps) {
const { t } = useTranslation('codeEditor');
const paletteOps = usePaletteOps();
const [isFullscreen, setIsFullscreen] = useState(false);
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
@@ -199,7 +201,7 @@ export default function CodeEditor({
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenSettings={() => window.openSettings?.('appearance')}
onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload}
onSave={handleSave}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}

View File

@@ -0,0 +1,373 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowDownToLine,
ArrowUpFromLine,
ChevronRight,
FileText,
GitCommit,
GitMerge,
MessageSquare,
MessageSquarePlus,
RefreshCw,
Settings,
SunMoon,
X,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Dialog,
DialogContent,
DialogTitle,
} from '../../shared/view/ui';
import { useTheme } from '../../contexts/ThemeContext';
import { usePaletteOps } from '../../contexts/PaletteOpsContext';
import { SETTINGS_MAIN_TABS } from '../settings/constants/constants';
import type { AppTab, Project } from '../../types/app';
import { useSessionsSource } from './sources/useSessionsSource';
import { useFilesSource } from './sources/useFilesSource';
import { useCommitsSource } from './sources/useCommitsSource';
import { useSessionMessageSearch } from './sources/useSessionMessageSearch';
import { useBranchesSource } from './sources/useBranchesSource';
import { useGitActions } from './sources/useGitActions';
type Page = 'actions' | 'files' | 'sessions' | 'commits' | 'branches';
const PAGE_LABELS: Record<Page, string> = {
actions: 'Actions',
files: 'Files',
sessions: 'Sessions',
commits: 'Commits',
branches: 'Branches',
};
type CommandPaletteProps = {
selectedProject: Project | null;
onStartNewChat: (project: Project) => void;
onOpenSettings: (tab?: string) => void;
onShowTab?: (tab: AppTab) => void;
};
const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [
{ id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' },
{ id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' },
{ id: 'shell', label: 'Go to Shell', keywords: 'shell terminal console' },
{ id: 'git', label: 'Go to Git', keywords: 'git diff branches' },
{ id: 'tasks', label: 'Go to Tasks', keywords: 'tasks taskmaster' },
];
export default function CommandPalette({
selectedProject,
onStartNewChat,
onOpenSettings,
onShowTab,
}: CommandPaletteProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const [pages, setPages] = React.useState<Page[]>([]);
const { toggleDarkMode } = useTheme();
const navigate = useNavigate();
const ops = usePaletteOps();
const page = pages.at(-1);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isCmdK = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k';
if (!isCmdK) return;
e.preventDefault();
setOpen((prev) => !prev);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
React.useEffect(() => {
if (!open) {
setSearch('');
setPages([]);
}
}, [open]);
const projectId = selectedProject?.projectId;
const showActions = !page || page === 'actions';
const showSessions = !page || page === 'sessions';
const showFiles = !page || page === 'files';
const showCommits = !page || page === 'commits';
const showBranches = !page || page === 'branches' || page === 'actions';
const sessions = useSessionsSource(projectId, open && showSessions);
const messageMatches = useSessionMessageSearch(projectId, search, open && showSessions);
const files = useFilesSource(projectId, open && showFiles);
const commits = useCommitsSource(projectId, open && showCommits);
const branches = useBranchesSource(projectId, open && showBranches);
const git = useGitActions(projectId);
const sessionRows = React.useMemo(() => {
if (!showSessions) return [];
type Row = { id: string; label: string; provider?: string; snippet?: string };
const byId = new Map<string, Row>();
for (const s of sessions) {
byId.set(s.id, { id: s.id, label: s.label, provider: s.provider });
}
for (const m of messageMatches) {
const existing = byId.get(m.sessionId);
if (existing) {
existing.snippet = m.snippet;
} else {
byId.set(m.sessionId, {
id: m.sessionId,
label: m.label,
provider: m.provider,
snippet: m.snippet,
});
}
}
return Array.from(byId.values());
}, [sessions, messageMatches, showSessions]);
const run = React.useCallback((fn: () => void) => {
setOpen(false);
fn();
}, []);
const pushPage = React.useCallback((next: Page) => {
setSearch('');
setPages((prev) => [...prev, next]);
}, []);
const popPage = React.useCallback(() => {
setSearch('');
setPages((prev) => prev.slice(0, -1));
}, []);
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !search && pages.length > 0) {
e.preventDefault();
popPage();
}
}, [search, pages.length, popPage]);
const startNewChatDisabled = !selectedProject;
const browseLimit = 5;
const filesShown = page === 'files' ? files : files.slice(0, browseLimit);
const commitsShown = page === 'commits' ? commits : commits.slice(0, browseLimit);
const sessionsShown = page === 'sessions' ? sessionRows : sessionRows.slice(0, browseLimit);
const branchesShown = page === 'branches' ? branches : branches.slice(0, browseLimit);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<DialogTitle>Command palette</DialogTitle>
<Command label="Command palette" onKeyDown={handleKeyDown}>
{page && (
<div className="flex items-center gap-2 border-b px-3 py-2">
<span className="inline-flex items-center gap-1 rounded-md bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
{PAGE_LABELS[page]}
<button
type="button"
onClick={popPage}
aria-label="Back to all"
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</span>
<span className="text-xs text-muted-foreground">Backspace to go back</span>
</div>
)}
<CommandInput
placeholder={page ? `Search ${PAGE_LABELS[page].toLowerCase()}` : 'Type to search anything…'}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
{showActions && (
<CommandGroup heading="Actions">
<CommandItem
value="Start new chat"
disabled={startNewChatDisabled}
onSelect={() => {
if (!selectedProject) return;
run(() => onStartNewChat(selectedProject));
}}
>
<MessageSquarePlus className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Start new chat</span>
{startNewChatDisabled && (
<span className="text-xs text-muted-foreground">Select a project first</span>
)}
</CommandItem>
<CommandItem value="Open settings" onSelect={() => run(() => onOpenSettings())}>
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Open settings</span>
</CommandItem>
<CommandItem value="Toggle theme dark light mode" onSelect={() => run(toggleDarkMode)}>
<SunMoon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Toggle theme</span>
</CommandItem>
</CommandGroup>
)}
{showActions && (
<CommandGroup heading="Navigate">
{NAV_TABS.map((tab) => (
<CommandItem
key={tab.id as string}
value={`${tab.label} ${tab.keywords}`}
onSelect={() => run(() => onShowTab?.(tab.id))}
>
<span className="flex-1">{tab.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{showActions && projectId && (
<CommandGroup heading="Git">
<CommandItem
value="Git Fetch remote"
onSelect={() => run(() => { void git.fetch(); onShowTab?.('git'); })}
>
<RefreshCw className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Fetch</span>
</CommandItem>
<CommandItem
value="Git Pull merge upstream"
onSelect={() => run(() => { void git.pull(); onShowTab?.('git'); })}
>
<ArrowDownToLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Pull</span>
</CommandItem>
<CommandItem
value="Git Push origin remote"
onSelect={() => run(() => { void git.push(); onShowTab?.('git'); })}
>
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Push</span>
</CommandItem>
</CommandGroup>
)}
{showActions && (
<CommandGroup heading="Settings">
{SETTINGS_MAIN_TABS.map(({ id, label, keywords, icon: Icon }) => (
<CommandItem
key={id}
value={`Settings ${label} ${keywords}`}
onSelect={() => run(() => onOpenSettings(id))}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Settings: {label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{showSessions && projectId && sessionsShown.length > 0 && (
<CommandGroup heading="Sessions">
{sessionsShown.map((s) => (
<CommandItem
key={s.id}
value={`${s.label} ${s.snippet ?? ''} ${s.id}`.trim()}
onSelect={() => run(() => navigate(`/session/${s.id}`))}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{s.label}</span>
{s.snippet && (
<span className="truncate text-xs text-muted-foreground">{s.snippet}</span>
)}
</div>
{s.provider && (
<span className="text-xs text-muted-foreground">{s.provider}</span>
)}
</CommandItem>
))}
{!page && sessionRows.length > browseLimit && (
<BrowseAllItem label={`Browse all sessions (${sessionRows.length})`} onSelect={() => pushPage('sessions')} />
)}
</CommandGroup>
)}
{showFiles && projectId && filesShown.length > 0 && (
<CommandGroup heading="Files">
{filesShown.map((f) => (
<CommandItem
key={f.path}
value={f.path}
onSelect={() => run(() => ops.openFile(f.path))}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 truncate">{f.name}</span>
<span className="truncate text-xs text-muted-foreground">{f.path}</span>
</CommandItem>
))}
{!page && files.length > browseLimit && (
<BrowseAllItem label={`Browse all files (${files.length})`} onSelect={() => pushPage('files')} />
)}
</CommandGroup>
)}
{showCommits && projectId && commitsShown.length > 0 && (
<CommandGroup heading="Commits">
{commitsShown.map((c) => (
<CommandItem
key={c.hash}
value={`${c.message} ${c.author} ${c.shortHash}`}
onSelect={() => run(() => onShowTab?.('git'))}
>
<GitCommit className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="font-mono text-xs text-muted-foreground">{c.shortHash}</span>
<span className="flex-1 truncate">{c.message}</span>
<span className="truncate text-xs text-muted-foreground">{c.author}</span>
</CommandItem>
))}
{!page && commits.length > browseLimit && (
<BrowseAllItem label={`Browse all commits (${commits.length})`} onSelect={() => pushPage('commits')} />
)}
</CommandGroup>
)}
{showBranches && projectId && branchesShown.length > 0 && (
<CommandGroup heading="Branches">
{branchesShown.map((b) => (
<CommandItem
key={`branch-${b.name}`}
value={b.name}
onSelect={() => run(() => { void git.checkout(b.name); onShowTab?.('git'); })}
>
<GitMerge className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 truncate">Switch to: {b.name}</span>
</CommandItem>
))}
{!page && branches.length > browseLimit && (
<BrowseAllItem label={`Browse all branches (${branches.length})`} onSelect={() => pushPage('branches')} />
)}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function BrowseAllItem({ label, onSelect }: { label: string; onSelect: () => void }) {
return (
<CommandItem value={label} onSelect={onSelect}>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 text-muted-foreground">{label}</span>
</CommandItem>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState, type DependencyList } from 'react';
export function useApiSource<T, R = unknown>(opts: {
enabled: boolean;
deps: DependencyList;
fetcher: (signal: AbortSignal) => Promise<Response>;
parse: (raw: R) => T[];
}): T[] {
const [items, setItems] = useState<T[]>([]);
const { enabled, deps, fetcher, parse } = opts;
useEffect(() => {
if (!enabled) {
setItems([]);
return;
}
const controller = new AbortController();
fetcher(controller.signal)
.then((r) => r.json() as Promise<R>)
.then((data) => {
if (controller.signal.aborted) return;
setItems(parse(data));
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
setItems([]);
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, ...deps]);
return items;
}

View File

@@ -0,0 +1,21 @@
import { authenticatedFetch } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type BranchResult = { name: string };
interface BranchesResponse {
localBranches?: string[];
}
export function useBranchesSource(projectId: string | undefined, enabled: boolean) {
return useApiSource<BranchResult, BranchesResponse>({
enabled: enabled && !!projectId,
deps: [projectId],
fetcher: (signal) => {
const params = new URLSearchParams({ project: projectId! });
return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal });
},
parse: (data) => (data.localBranches ?? []).map((name) => ({ name })),
});
}

View File

@@ -0,0 +1,35 @@
import { authenticatedFetch } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type CommitResult = {
hash: string;
shortHash: string;
message: string;
author: string;
};
interface CommitsResponse {
commits?: Array<{ hash: string; message: string; author: string }>;
error?: string;
}
export function useCommitsSource(projectId: string | undefined, enabled: boolean) {
return useApiSource<CommitResult, CommitsResponse>({
enabled: enabled && !!projectId,
deps: [projectId],
fetcher: (signal) => {
const params = new URLSearchParams({ project: projectId!, limit: '50' });
return authenticatedFetch(`/api/git/commits?${params.toString()}`, { signal });
},
parse: (data) => {
if (!data.commits) return [];
return data.commits.map<CommitResult>((c) => ({
hash: c.hash,
shortHash: c.hash.slice(0, 7),
message: c.message,
author: c.author,
}));
},
});
}

View File

@@ -0,0 +1,42 @@
import { api } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type FileResult = {
path: string;
name: string;
};
interface FileNode {
type: 'file' | 'directory';
name: string;
path: string;
children?: FileNode[];
}
const MAX_FILES = 500;
function flatten(nodes: FileNode[], out: FileResult[]): void {
for (const node of nodes) {
if (out.length >= MAX_FILES) return;
if (node.type === 'file') {
out.push({ path: node.path, name: node.name });
} else if (node.children && node.children.length > 0) {
flatten(node.children, out);
}
}
}
export function useFilesSource(projectId: string | undefined, enabled: boolean) {
return useApiSource<FileResult, unknown>({
enabled: enabled && !!projectId,
deps: [projectId],
fetcher: (signal) => api.getFiles(projectId!, { signal }),
parse: (data) => {
const tree: FileNode[] = Array.isArray(data) ? (data as FileNode[]) : [];
const flat: FileResult[] = [];
flatten(tree, flat);
return flat;
},
});
}

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { authenticatedFetch } from '../../../utils/api';
async function postGit(path: string, body: Record<string, unknown>) {
const res = await authenticatedFetch(path, {
method: 'POST',
body: JSON.stringify(body),
});
return res.json();
}
export function useGitActions(projectId: string | undefined) {
const fetch = useCallback(() => {
if (!projectId) return Promise.resolve();
return postGit('/api/git/fetch', { project: projectId });
}, [projectId]);
const pull = useCallback(() => {
if (!projectId) return Promise.resolve();
return postGit('/api/git/pull', { project: projectId });
}, [projectId]);
const push = useCallback(() => {
if (!projectId) return Promise.resolve();
return postGit('/api/git/push', { project: projectId });
}, [projectId]);
const checkout = useCallback(
(branch: string) => {
if (!projectId) return Promise.resolve();
return postGit('/api/git/checkout', { project: projectId, branch });
},
[projectId],
);
return { fetch, pull, push, checkout };
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../../../utils/api';
import type { LLMProvider } from '../../../types/app';
export type SessionMessageMatch = {
sessionId: string;
label: string;
snippet: string;
provider: LLMProvider;
};
type ProjectResult = {
projectId: string | null;
projectName: string;
sessions: Array<{
sessionId: string;
provider: LLMProvider;
sessionSummary: string;
matches: Array<{ snippet: string }>;
}>;
};
const MIN_QUERY = 2;
const DEBOUNCE_MS = 250;
export function useSessionMessageSearch(
projectId: string | undefined,
query: string,
enabled: boolean,
) {
const [items, setItems] = useState<SessionMessageMatch[]>([]);
const seqRef = useRef(0);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
const trimmed = query.trim();
if (!enabled || !projectId || trimmed.length < MIN_QUERY) {
setItems([]);
esRef.current?.close();
esRef.current = null;
return;
}
esRef.current?.close();
esRef.current = null;
seqRef.current++;
const handle = setTimeout(() => {
const seq = ++seqRef.current;
const url = api.searchConversationsUrl(trimmed);
const es = new EventSource(url);
esRef.current = es;
const accumulated: SessionMessageMatch[] = [];
es.addEventListener('result', (evt) => {
if (seq !== seqRef.current) {
es.close();
return;
}
try {
const data = JSON.parse((evt as MessageEvent).data) as { projectResult: ProjectResult };
const pr = data.projectResult;
if (pr.projectId !== projectId) return;
for (const s of pr.sessions) {
accumulated.push({
sessionId: s.sessionId,
label: s.sessionSummary || s.sessionId,
snippet: s.matches[0]?.snippet ?? '',
provider: s.provider,
});
}
setItems([...accumulated]);
} catch {
// ignore malformed
}
});
const finish = () => {
if (seq !== seqRef.current) return;
es.close();
esRef.current = null;
};
es.addEventListener('done', finish);
es.addEventListener('error', finish);
}, DEBOUNCE_MS);
return () => {
clearTimeout(handle);
};
}, [projectId, query, enabled]);
useEffect(() => {
return () => {
esRef.current?.close();
esRef.current = null;
};
}, []);
return items;
}

View File

@@ -0,0 +1,44 @@
import { authenticatedFetch } from '../../../utils/api';
import type { LLMProvider, ProjectSession } from '../../../types/app';
import { useApiSource } from './useApiSource';
export type SessionResult = {
id: string;
label: string;
provider?: LLMProvider;
};
interface SessionsResponse {
sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
}
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
return useApiSource<SessionResult, SessionsResponse>({
enabled: enabled && !!projectId,
deps: [projectId],
fetcher: (signal) => {
const params = new URLSearchParams({ limit: '50', offset: '0' });
return authenticatedFetch(
`/api/projects/${encodeURIComponent(projectId!)}/sessions?${params.toString()}`,
{ signal },
);
},
parse: (data) => {
const all: ProjectSession[] = [
...(data.sessions ?? []),
...(data.cursorSessions ?? []),
...(data.codexSessions ?? []),
...(data.geminiSessions ?? []),
];
return all.map<SessionResult>((s) => ({
id: s.id,
label: (s.title || s.summary || s.name || s.id) as string,
provider: s.__provider,
}));
},
});
}

View File

@@ -1,5 +1,7 @@
import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app';
import type { SessionNavigationOptions } from '../../chat/types/types';
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
@@ -50,9 +52,10 @@ export type MainContentProps = {
onSessionNotProcessing: SessionLifecycleHandler;
processingSessions: Set<string>;
onReplaceTemporarySession: SessionLifecycleHandler;
onNavigateToSession: (targetSessionId: string) => void;
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
onShowSettings: () => void;
externalMessageUpdate: number;
newSessionTrigger: number;
};
export type MainContentHeaderProps = {

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
@@ -6,12 +7,14 @@ import GitPanel from '../../git-panel/view/GitPanel';
import PluginTabContent from '../../plugins/view/PluginTabContent';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar';
import type { Project } from '../../../types/app';
import { TaskMasterPanel } from '../../task-master';
import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView';
import ErrorBoundary from './ErrorBoundary';
@@ -48,6 +51,7 @@ function MainContent({
onNavigateToSession,
onShowSettings,
externalMessageUpdate,
newSessionTrigger,
}: MainContentProps) {
const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
@@ -89,6 +93,13 @@ function MainContent({
}
}, [shouldShowTasksTab, activeTab, setActiveTab]);
usePaletteOpsRegister({
openFile: (filePath: string) => {
setActiveTab('files');
handleFileOpen(filePath);
},
});
if (isLoading) {
return <MainContentStateView mode="loading" isMobile={isMobile} onMenuClick={onMenuClick} />;
}
@@ -135,6 +146,7 @@ function MainContent({
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
newSessionTrigger={newSessionTrigger}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
/>
</ErrorBoundary>

View File

@@ -1,3 +1,15 @@
import type { ComponentType } from 'react';
import {
Bell,
Bot,
GitBranch,
Info,
KeyRound,
ListChecks,
Palette,
Plug,
} from 'lucide-react';
import type {
AgentCategory,
AgentProvider,
@@ -7,13 +19,22 @@ import type {
SettingsMainTab,
} from '../types/types';
export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
'agents',
'appearance',
'git',
'api',
'tasks',
'notifications',
export type SettingsMainTabMeta = {
id: SettingsMainTab;
label: string;
keywords: string;
icon: ComponentType<{ className?: string }>;
};
export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [
{ id: 'agents', label: 'Agents', keywords: 'agents subagents claude code', icon: Bot },
{ id: 'appearance', label: 'Appearance', keywords: 'appearance theme dark light language', icon: Palette },
{ id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch },
{ id: 'api', label: 'API Tokens', keywords: 'api tokens auth keys', icon: KeyRound },
{ id: 'tasks', label: 'Tasks', keywords: 'tasks taskmaster', icon: ListChecks },
{ id: 'notifications', label: 'Notifications', keywords: 'notifications alerts push', icon: Bell },
{ id: 'plugins', label: 'Plugins', keywords: 'plugins extensions integrations', icon: Plug },
{ id: 'about', label: 'About', keywords: 'about version info', icon: Info },
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
@@ -34,4 +55,3 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
disallowedCommands: [],
skipPermissions: false,
};

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import { api } from '../../../utils/api';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
DeleteProjectConfirmation,
@@ -95,6 +96,7 @@ export function useSidebarController({
setSidebarVisible,
sidebarVisible,
}: UseSidebarControllerArgs) {
const paletteOps = usePaletteOps();
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
const [editingProject, setEditingProject] = useState<string | null>(null);
const [showNewProject, setShowNewProject] = useState(false);
@@ -536,11 +538,7 @@ export function useSidebarController({
try {
const response = await api.renameProject(projectId, editingName);
if (response.ok) {
if (window.refreshProjects) {
await window.refreshProjects();
} else {
window.location.reload();
}
await paletteOps.refreshProjects();
} else {
console.error('Failed to rename project');
}
@@ -551,7 +549,7 @@ export function useSidebarController({
setEditingName('');
}
},
[editingName],
[editingName, paletteOps],
);
const showDeleteSessionConfirmation = useCallback(

View File

@@ -6,6 +6,7 @@ import { useVersionCheck } from '../../../hooks/useVersionCheck';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useSidebarController } from '../hooks/useSidebarController';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import type { Project, LLMProvider } from '../../../types/app';
import type { MCPServerStatus, SidebarProps } from '../types/types';
@@ -49,6 +50,7 @@ function Sidebar({
const { sidebarVisible } = preferences;
const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext;
const { tasksEnabled } = useTasksSettings();
const paletteOps = usePaletteOps();
const {
isSidebarCollapsed,
@@ -128,12 +130,7 @@ function Sidebar({
}, [isPWA]);
const handleProjectCreated = () => {
if (window.refreshProjects) {
void window.refreshProjects();
return;
}
window.location.reload();
void paletteOps.refreshProjects();
};
const projectListProps: SidebarProjectListProps = {

View File

@@ -5,6 +5,9 @@ import { IS_PLATFORM } from '../../../../constants/config';
import { cn } from '../../../../lib/utils';
import GitHubStarBadge from './GitHubStarBadge';
const MOD_KEY =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl';
type SearchMode = 'projects' | 'conversations';
type SidebarHeaderProps = {
@@ -148,9 +151,9 @@ export default function SidebarHeader({
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(event) => onSearchFilterChange(event.target.value)}
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-14 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
{searchFilter && (
{searchFilter ? (
<button
onClick={onClearSearchFilter}
aria-label={t('tooltips.clearSearch')}
@@ -158,6 +161,15 @@ export default function SidebarHeader({
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<kbd
aria-hidden
title={t('tooltips.openCommandPalette')}
className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground md:inline-flex"
>
{MOD_KEY}
<span>K</span>
</kbd>
)}
</div>
</div>

View File

@@ -0,0 +1,53 @@
import { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import type { MutableRefObject, ReactNode } from 'react';
export type PaletteOps = {
openFile: (path: string) => void;
openSettings: (tab?: string) => void;
refreshProjects: () => Promise<void> | void;
};
type Registry = MutableRefObject<Partial<PaletteOps>>;
const PaletteOpsContext = createContext<Registry | null>(null);
const defaultOps: PaletteOps = {
openFile: () => undefined,
openSettings: () => undefined,
refreshProjects: () => undefined,
};
export function PaletteOpsProvider({ children }: { children: ReactNode }) {
const ref = useRef<Partial<PaletteOps>>({});
return <PaletteOpsContext.Provider value={ref}>{children}</PaletteOpsContext.Provider>;
}
export function usePaletteOps(): PaletteOps {
const ref = useContext(PaletteOpsContext);
return useMemo<PaletteOps>(
() => ({
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
}),
[ref],
);
}
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
const ref = useContext(PaletteOpsContext);
const { openFile, openSettings, refreshProjects } = partial;
useEffect(() => {
if (!ref) return undefined;
const prev = { ...ref.current };
if (openFile) ref.current.openFile = openFile;
if (openSettings) ref.current.openSettings = openSettings;
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
return () => {
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
};
}, [ref, openFile, openSettings, refreshProjects]);
}

View File

@@ -5,6 +5,7 @@ import { api } from '../utils/api';
import type {
AppSocketMessage,
AppTab,
LLMProvider,
LoadingProgress,
Project,
ProjectSession,
@@ -261,6 +262,27 @@ export function useProjectsState({
const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
/**
* `newSessionTrigger` is an explicit, monotonic intent signal for user-driven
* New Session actions.
*
* It exists because `handleNewSession` can be invoked while the app is already in
* the same visible state (`selectedSession === null`, `activeTab === 'chat'`,
* route already `/`). In that case, React/router updates are idempotent and no
* downstream reset logic runs.
*
* Usage across the codebase:
* 1) Produced here in `handleNewSession` via increment (always changes).
* 2) Returned from this hook and threaded through:
* useProjectsState -> AppContent -> MainContent -> ChatInterface.
* 3) Consumed in `useChatSessionState` as an effect dependency to forcibly clear
* chat-local state (`currentSessionId`, pending draft message, streaming flags,
* pending session storage keys, pagination/scroll artifacts).
*
* Keeping this signal dedicated avoids coupling resets to unrelated counters/events
* (for example websocket/project refresh updates) that could cause accidental resets.
*/
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
@@ -536,7 +558,42 @@ export function useProjectsState({
return;
}
}
}, [sessionId, projects, selectedProject?.projectId, selectedSession?.id, selectedSession?.__provider]);
// Session id is in the URL but not yet present on any project payload (common
// right after `session_created` + navigate, before the next projects refresh).
// Without a `selectedSession`, chat state clears `currentSessionId` and the
// UI stops reading the session store even though messages stream under this id.
if (selectedSession?.id === sessionId) {
return;
}
if (!selectedProject) {
return;
}
let providerFromStorage: string | null = null;
try {
providerFromStorage = localStorage.getItem('selected-provider');
} catch {
providerFromStorage = null;
}
const normalizedProvider: LLMProvider =
providerFromStorage === 'cursor'
? 'cursor'
: providerFromStorage === 'codex'
? 'codex'
: providerFromStorage === 'gemini'
? 'gemini'
: 'claude';
setSelectedSession({
id: sessionId,
__provider: normalizedProvider,
__projectId: selectedProject.projectId,
summary: '',
});
}, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]);
const handleProjectSelect = useCallback(
(project: Project) => {
@@ -587,6 +644,7 @@ export function useProjectsState({
setSelectedProject(project);
setSelectedSession(null);
setActiveTab('chat');
setNewSessionTrigger((previous) => previous + 1);
navigate('/');
if (isMobile) {
@@ -806,6 +864,7 @@ export function useProjectsState({
showSettings,
settingsInitialTab,
externalMessageUpdate,
newSessionTrigger,
setActiveTab,
setSidebarOpen,
setIsInputFocused,

View File

@@ -89,12 +89,14 @@
"permissionMode": "Berechtigungsmodus",
"modes": {
"default": "Standardmodus",
"auto": "Auto Mode",
"acceptEdits": "Bearbeitungen akzeptieren",
"bypassPermissions": "Berechtigungen umgehen",
"plan": "Planungsmodus"
},
"descriptions": {
"default": "Nur vertrauenswürdige Befehle (ls, cat, grep, git status usw.) werden automatisch ausgeführt. Andere Befehle werden übersprungen. Kann in den Arbeitsbereich schreiben.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Alle Befehle werden automatisch innerhalb des Arbeitsbereichs ausgeführt. Vollautomatischer Modus mit isolierter Ausführung.",
"bypassPermissions": "Vollständiger Systemzugriff ohne Einschränkungen. Alle Befehle werden automatisch mit vollem Festplatten- und Netzwerkzugriff ausgeführt. Mit Vorsicht verwenden.",
"plan": "Planungsmodus keine Befehle werden ausgeführt"
@@ -188,7 +190,8 @@
"codex": "Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.",
"gemini": "Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.",
"default": "Wähl oben einen Anbieter, um zu beginnen"
}
},
"pressToSearch": "Drücke <kbd>{{shortcut}}</kbd>, um Sitzungen, Dateien und Commits zu durchsuchen"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "Diese Sitzung dauerhaft löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"clearSearch": "Suche leeren"
"clearSearch": "Suche leeren",
"openCommandPalette": "Befehlspalette öffnen"
},
"navigation": {
"chat": "Chat",

View File

@@ -89,12 +89,14 @@
"permissionMode": "Permission Mode",
"modes": {
"default": "Default Mode",
"auto": "Auto Mode",
"acceptEdits": "Accept Edits",
"bypassPermissions": "Bypass Permissions",
"plan": "Plan Mode"
},
"descriptions": {
"default": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "All commands run automatically within the workspace. Full auto mode with sandboxed execution.",
"bypassPermissions": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.",
"plan": "Planning mode - no commands are executed"
@@ -188,7 +190,8 @@
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"gemini": "Ready to use Gemini with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
}
},
"pressToSearch": "Press <kbd>{{shortcut}}</kbd> to search sessions, files, and commits"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "Delete this session permanently",
"save": "Save",
"cancel": "Cancel",
"clearSearch": "Clear search"
"clearSearch": "Clear search",
"openCommandPalette": "Open command palette"
},
"navigation": {
"chat": "Chat",

View File

@@ -89,12 +89,14 @@
"permissionMode": "Modalità permessi",
"modes": {
"default": "Modalità predefinita",
"auto": "Auto Mode",
"acceptEdits": "Accetta modifiche",
"bypassPermissions": "Ignora permessi",
"plan": "Modalità piano"
},
"descriptions": {
"default": "Solo i comandi attendibili (ls, cat, grep, git status, ecc.) vengono eseguiti automaticamente. Gli altri comandi vengono saltati. Può scrivere nell'area di lavoro.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Tutti i comandi vengono eseguiti automaticamente nell'area di lavoro. Modalità completamente automatica con esecuzione sandboxed.",
"bypassPermissions": "Accesso completo al sistema senza restrizioni. Tutti i comandi vengono eseguiti automaticamente con accesso completo a disco e rete. Usa con cautela.",
"plan": "Modalità pianificazione - nessun comando viene eseguito"
@@ -188,7 +190,8 @@
"codex": "Pronto a usare Codex con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"gemini": "Pronto a usare Gemini con {{model}}. Inizia a digitare il tuo messaggio qui sotto.",
"default": "Seleziona un provider sopra per iniziare"
}
},
"pressToSearch": "Premi <kbd>{{shortcut}}</kbd> per cercare sessioni, file e commit"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "Elimina questa sessione permanentemente",
"save": "Salva",
"cancel": "Annulla",
"clearSearch": "Cancella ricerca"
"clearSearch": "Cancella ricerca",
"openCommandPalette": "Apri tavolozza comandi"
},
"navigation": {
"chat": "Chat",

View File

@@ -88,12 +88,14 @@
"permissionMode": "権限モード",
"modes": {
"default": "デフォルトモード",
"auto": "Auto Mode",
"acceptEdits": "編集を許可",
"bypassPermissions": "権限をバイパス",
"plan": "プランモード"
},
"descriptions": {
"default": "信頼されたコマンドls、cat、grep、git statusなどのみ自動実行。その他のコマンドはスキップ。ワークスペースへの書き込みは可能。",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "ワークスペース内ですべてのコマンドを自動実行。サンドボックス環境での完全自動モード。",
"bypassPermissions": "制限なしの完全なシステムアクセス。すべてのコマンドがディスクとネットワークへの完全なアクセスで自動実行されます。注意して使用してください。",
"plan": "プランニングモード - コマンドは実行されません"
@@ -165,7 +167,8 @@
"cursor": "{{model}}でCursorを使用する準備ができました。下にメッセージを入力してください。",
"codex": "{{model}}でCodexを使用する準備ができました。下にメッセージを入力してください。",
"default": "上からプロバイダーを選択して開始してください"
}
},
"pressToSearch": "<kbd>{{shortcut}}</kbd> を押してセッション、ファイル、コミットを検索"
},
"session": {
"continue": {

View File

@@ -46,7 +46,8 @@
"editSessionName": "セッション名を手動で編集",
"deleteSession": "このセッションを完全に削除",
"save": "保存",
"cancel": "キャンセル"
"cancel": "キャンセル",
"openCommandPalette": "コマンドパレットを開く"
},
"navigation": {
"chat": "チャット",

View File

@@ -89,12 +89,14 @@
"permissionMode": "권한 모드",
"modes": {
"default": "기본 모드",
"auto": "Auto Mode",
"acceptEdits": "편집 허용",
"bypassPermissions": "권한 우회",
"plan": "Plan 모드"
},
"descriptions": {
"default": "신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.",
"bypassPermissions": "제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.",
"plan": "계획 모드 - 명령어가 실행되지 않습니다"
@@ -170,7 +172,8 @@
"codex": "{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"gemini": "{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.",
"default": "시작하려면 위에서 제공자를 선택하세요"
}
},
"pressToSearch": "<kbd>{{shortcut}}</kbd>를 눌러 세션, 파일 및 커밋을 검색하세요"
},
"session": {
"continue": {

View File

@@ -46,7 +46,8 @@
"editSessionName": "세션 이름 직접 편집",
"deleteSession": "이 세션 영구 삭제",
"save": "저장",
"cancel": "취소"
"cancel": "취소",
"openCommandPalette": "명령 팔레트 열기"
},
"navigation": {
"chat": "채팅",

View File

@@ -89,12 +89,14 @@
"permissionMode": "Режим разрешений",
"modes": {
"default": "Режим по умолчанию",
"auto": "Auto Mode",
"acceptEdits": "Принимать правки",
"bypassPermissions": "Обход разрешений",
"plan": "Режим планирования"
},
"descriptions": {
"default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.",
"bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.",
"plan": "Режим планирования - команды не выполняются"
@@ -188,7 +190,8 @@
"codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.",
"gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.",
"default": "Выберите провайдера выше для начала"
}
},
"pressToSearch": "Нажмите <kbd>{{shortcut}}</kbd>, чтобы искать сессии, файлы и коммиты"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "Удалить этот сеанс навсегда",
"save": "Сохранить",
"cancel": "Отмена",
"clearSearch": "Очистить поиск"
"clearSearch": "Очистить поиск",
"openCommandPalette": "Открыть палитру команд"
},
"navigation": {
"chat": "Чат",

View File

@@ -89,12 +89,14 @@
"permissionMode": "İzin Modu",
"modes": {
"default": "Varsayılan Mod",
"auto": "Auto Mode",
"acceptEdits": "Düzenlemeleri Kabul Et",
"bypassPermissions": "İzinleri Atla",
"plan": "Plan Modu"
},
"descriptions": {
"default": "Sadece güvenilir komutlar (ls, cat, grep, git status, vb.) otomatik çalışır. Diğer komutlar atlanır. Çalışma alanına yazabilir.",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "Tüm komutlar çalışma alanı içinde otomatik çalışır. Sandbox'lu çalıştırma ile tam otomatik mod.",
"bypassPermissions": "Kısıtlama olmadan tam sistem erişimi. Tüm komutlar tam disk ve ağ erişimiyle otomatik çalışır. Dikkatli kullan.",
"plan": "Planlama modu — hiçbir komut çalıştırılmaz"
@@ -188,7 +190,8 @@
"codex": "Codex'i {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"gemini": "Gemini'yi {{model}} ile kullanmaya hazır. Mesajını aşağıya yazmaya başla.",
"default": "Başlamak için yukarıdan bir sağlayıcı seç"
}
},
"pressToSearch": "Oturumlarda, dosyalarda ve commit'lerde arama yapmak için <kbd>{{shortcut}}</kbd> tuşlarına bas"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "Bu oturumu kalıcı olarak sil",
"save": "Kaydet",
"cancel": "İptal",
"clearSearch": "Aramayı temizle"
"clearSearch": "Aramayı temizle",
"openCommandPalette": "Komut paletini aç"
},
"navigation": {
"chat": "Sohbet",

View File

@@ -89,12 +89,14 @@
"permissionMode": "权限模式",
"modes": {
"default": "默认模式",
"auto": "Auto Mode",
"acceptEdits": "编辑模式",
"bypassPermissions": "无限制模式",
"plan": "计划模式"
},
"descriptions": {
"default": "只有受信任的命令ls、cat、grep、git status 等)自动运行。其他命令将被跳过。可以写入工作区。",
"auto": "A model classifier decides per tool call whether to approve or deny. Hands-off, but safer than Bypass — denials still happen.",
"acceptEdits": "工作区内的所有命令自动运行。完全自动模式,具有沙盒执行功能。",
"bypassPermissions": "完全的系统访问,无限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。",
"plan": "计划模式 - 不执行任何命令"
@@ -170,7 +172,8 @@
"codex": "准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。",
"gemini": "准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。",
"default": "请在上方选择一个提供者以开始"
}
},
"pressToSearch": "按 <kbd>{{shortcut}}</kbd> 搜索会话、文件和提交"
},
"session": {
"continue": {

View File

@@ -47,7 +47,8 @@
"deleteSession": "永久删除此会话",
"save": "保存",
"cancel": "取消",
"clearSearch": "清除搜索"
"clearSearch": "清除搜索",
"openCommandPalette": "打开命令面板"
},
"navigation": {
"chat": "聊天",

View File

@@ -1,320 +1,107 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '../../../lib/utils';
/*
* Lightweight command palette — inspired by cmdk but no external deps.
*
* Architecture:
* - Command owns the search string and a flat list of registered item values.
* - Items register via context on mount and deregister on unmount.
* - Filtering, active index, and keyboard nav happen centrally in Command.
* - Items read their "is visible" / "is active" state from context.
*/
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn('flex flex-col', className)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface ItemEntry {
id: string;
value: string; // searchable text (lowercase)
onSelect: () => void;
element: HTMLElement | null;
}
interface CommandContextValue {
search: string;
setSearch: (value: string) => void;
/** Set of visible item IDs after filtering (derived state, not a ref). */
visibleIds: Set<string>;
activeId: string | null;
setActiveId: (id: string | null) => void;
register: (entry: ItemEntry) => void;
unregister: (id: string) => void;
updateEntry: (id: string, patch: Partial<Pick<ItemEntry, 'value' | 'onSelect' | 'element'>>) => void;
}
const CommandContext = React.createContext<CommandContextValue | null>(null);
function useCommand() {
const ctx = React.useContext(CommandContext);
if (!ctx) throw new Error('Command components must be used within <Command>');
return ctx;
}
/* ─── Command (root) ─────────────────────────────────────────────── */
type CommandProps = React.HTMLAttributes<HTMLDivElement>;
const Command = React.forwardRef<HTMLDivElement, CommandProps>(
({ className, children, ...props }, ref) => {
const [search, setSearch] = React.useState('');
const entriesRef = React.useRef<Map<string, ItemEntry>>(new Map());
// Bump this counter whenever the entry set changes so derived state recalculates
const [revision, setRevision] = React.useState(0);
const register = React.useCallback((entry: ItemEntry) => {
entriesRef.current.set(entry.id, entry);
setRevision(r => r + 1);
}, []);
const unregister = React.useCallback((id: string) => {
entriesRef.current.delete(id);
setRevision(r => r + 1);
}, []);
const updateEntry = React.useCallback((id: string, patch: Partial<Pick<ItemEntry, 'value' | 'onSelect' | 'element'>>) => {
const existing = entriesRef.current.get(id);
if (existing) {
Object.assign(existing, patch);
}
}, []);
// Derive visible IDs from search + entries
const visibleIds = React.useMemo(() => {
const lowerSearch = search.toLowerCase();
const ids = new Set<string>();
for (const [id, entry] of entriesRef.current) {
if (!lowerSearch || entry.value.includes(lowerSearch)) {
ids.add(id);
}
}
return ids;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, revision]);
// Ordered list of visible entries (preserves DOM order via insertion order)
const visibleEntries = React.useMemo(() => {
const result: ItemEntry[] = [];
for (const [, entry] of entriesRef.current) {
if (visibleIds.has(entry.id)) result.push(entry);
}
return result;
}, [visibleIds]);
// Active item tracking
const [activeId, setActiveId] = React.useState<string | null>(null);
// Reset active to first visible item when search or visible set changes
React.useEffect(() => {
setActiveId(visibleEntries.length > 0 ? visibleEntries[0].id : null);
}, [visibleEntries]);
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
e.preventDefault();
} else {
return;
}
const entries = visibleEntries;
if (entries.length === 0) return;
if (e.key === 'Enter') {
const active = entries.find(entry => entry.id === activeId);
active?.onSelect();
return;
}
const currentIndex = entries.findIndex(entry => entry.id === activeId);
let nextIndex: number;
if (e.key === 'ArrowDown') {
nextIndex = currentIndex < entries.length - 1 ? currentIndex + 1 : 0;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : entries.length - 1;
}
const nextId = entries[nextIndex].id;
setActiveId(nextId);
// Scroll the active item into view
const nextEntry = entries[nextIndex];
nextEntry.element?.scrollIntoView({ block: 'nearest' });
}, [visibleEntries, activeId]);
const value = React.useMemo<CommandContextValue>(
() => ({ search, setSearch, visibleIds, activeId, setActiveId, register, unregister, updateEntry }),
[search, visibleIds, activeId, register, unregister, updateEntry]
);
return (
<CommandContext.Provider value={value}>
<div
ref={ref}
role="combobox"
aria-expanded="true"
aria-haspopup="listbox"
className={cn('flex flex-col', className)}
onKeyDown={handleKeyDown}
{...props}
>
{children}
</div>
</CommandContext.Provider>
);
}
);
Command.displayName = 'Command';
/* ─── CommandInput ───────────────────────────────────────────────── */
type CommandInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'>;
const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(
({ className, placeholder = 'Search...', ...props }, ref) => {
const { search, setSearch } = useCommand();
return (
<div className="flex items-center border-b px-3" role="presentation">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={ref}
type="text"
role="searchbox"
aria-autocomplete="list"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none',
'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
);
}
);
CommandInput.displayName = 'CommandInput';
/* ─── CommandList ────────────────────────────────────────────────── */
const CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<CommandPrimitive.Input
ref={ref}
role="listbox"
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none',
'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
CommandList.displayName = 'CommandList';
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
/* ─── CommandEmpty ───────────────────────────────────────────────── */
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { search, visibleIds } = useCommand();
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
// Only show when there's a search term and zero matches
if (!search || visibleIds.size > 0) return null;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground',
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
return (
<div ref={ref} className={cn('py-6 text-center text-sm text-muted-foreground', className)} {...props} />
);
}
);
CommandEmpty.displayName = 'CommandEmpty';
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
'data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground',
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
/* ─── CommandGroup ───────────────────────────────────────────────── */
interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
heading?: React.ReactNode;
}
const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(
({ className, heading, children, ...props }, ref) => (
<div ref={ref} className={cn('overflow-hidden p-1', className)} role="group" aria-label={typeof heading === 'string' ? heading : undefined} {...props}>
{heading && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground" role="presentation">
{heading}
</div>
)}
{children}
</div>
)
);
CommandGroup.displayName = 'CommandGroup';
/* ─── CommandItem ────────────────────────────────────────────────── */
interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {
value?: string;
onSelect?: () => void;
disabled?: boolean;
}
const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(
({ className, value, onSelect, disabled, children, ...props }, ref) => {
const { visibleIds, activeId, setActiveId, register, unregister, updateEntry } = useCommand();
const stableId = React.useId();
const elementRef = React.useRef<HTMLElement | null>(null);
const searchableText = value || (typeof children === 'string' ? children : '');
// Register on mount, unregister on unmount
React.useEffect(() => {
register({
id: stableId,
value: searchableText.toLowerCase(),
onSelect: onSelect || (() => {}),
element: elementRef.current,
});
return () => unregister(stableId);
// Only re-register when the identity changes, not onSelect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableId, searchableText, register, unregister]);
// Keep onSelect up-to-date without re-registering
React.useEffect(() => {
updateEntry(stableId, { onSelect: onSelect || (() => {}) });
}, [stableId, onSelect, updateEntry]);
// Keep element ref up-to-date
const setRef = React.useCallback((node: HTMLDivElement | null) => {
elementRef.current = node;
updateEntry(stableId, { element: node });
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}, [stableId, updateEntry, ref]);
// Hidden by filter
if (!visibleIds.has(stableId)) return null;
const isActive = activeId === stableId;
return (
<div
ref={setRef}
role="option"
aria-selected={isActive}
aria-disabled={disabled || undefined}
data-active={isActive || undefined}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
isActive && 'bg-accent text-accent-foreground',
disabled && 'pointer-events-none opacity-50',
className
)}
onPointerMove={() => { if (!disabled && activeId !== stableId) setActiveId(stableId); }}
onClick={() => !disabled && onSelect?.()}
{...props}
>
{children}
</div>
);
}
);
CommandItem.displayName = 'CommandItem';
/* ─── CommandSeparator ───────────────────────────────────────────── */
const CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
)
);
CommandSeparator.displayName = 'CommandSeparator';
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator };

View File

@@ -104,17 +104,126 @@ function createEmptySlot(): SessionSlot {
}
/**
* Compute merged messages: server + realtime, deduped by id.
* Server messages take priority (they're the persisted source of truth).
* Realtime messages that aren't yet in server stay (in-flight streaming).
* Compute merged messages: server + realtime, deduped by id and adjacent
* assistant echo (same trimmed text), so finalized stream rows do not stack
* on top of the persisted copy before realtime is cleared.
*/
function userTextFingerprint(m: NormalizedMessage): string | null {
if (m.kind !== 'text' || m.role !== 'user') return null;
const t = (m.content || '').trim();
return t.length > 0 ? t : null;
}
/**
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
* while the sessions API soon returns the same reply with a different id.
* Those sit back-to-back in merged order and look like duplicate bubbles until
* `refreshFromServer` clears realtime. Collapse same-text assistant rows and
* stream_placeholder → text when content matches.
*/
function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedMessage[] {
const out: NormalizedMessage[] = [];
for (const m of merged) {
const prev = out[out.length - 1];
if (prev) {
if (prev.kind === 'stream_delta' && m.kind === 'text' && m.role === 'assistant') {
const ps = (prev.content || '').trim();
const ms = (m.content || '').trim();
if (ps.length > 0 && ps === ms) {
out[out.length - 1] = m;
continue;
}
}
if (
prev.kind === 'text'
&& m.kind === 'text'
&& prev.role === 'assistant'
&& m.role === 'assistant'
) {
const ms = (m.content || '').trim();
if (ms.length > 0 && ms === (prev.content || '').trim()) {
continue;
}
}
}
out.push(m);
}
return out;
}
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
if (realtime.length === 0) return server;
if (server.length === 0) return realtime;
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
const serverIds = new Set(server.map(m => m.id));
const extra = realtime.filter(m => !serverIds.has(m.id));
const serverUserTexts = new Set(
server.map(userTextFingerprint).filter((t): t is string => t !== null),
);
const extra = realtime.filter((m) => {
if (serverIds.has(m.id)) return false;
// Optimistic user rows use `local_*` ids; once the same text exists on the
// server-backed copy, drop the realtime echo to avoid duplicate bubbles.
if (m.id.startsWith('local_')) {
const fp = userTextFingerprint(m);
if (fp && serverUserTexts.has(fp)) return false;
}
return true;
});
if (extra.length === 0) return server;
return [...server, ...extra];
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
}
function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number {
const leftTime = Date.parse(left.timestamp);
const rightTime = Date.parse(right.timestamp);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) {
return 0;
}
return leftTime - rightTime;
}
function rewriteMessageSessionId(
msg: NormalizedMessage,
fromSessionId: string,
toSessionId: string,
): NormalizedMessage {
const streamingSourceId = `__streaming_${fromSessionId}`;
const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id;
if (msg.sessionId === toSessionId && nextId === msg.id) {
return msg;
}
return {
...msg,
id: nextId,
sessionId: toSessionId,
};
}
function mergeMessagesById(
existing: NormalizedMessage[],
incoming: NormalizedMessage[],
): NormalizedMessage[] {
if (existing.length === 0) return incoming;
if (incoming.length === 0) return existing;
const merged = [...existing, ...incoming];
const deduped: NormalizedMessage[] = [];
const seen = new Set<string>();
for (const msg of merged) {
if (seen.has(msg.id)) {
continue;
}
seen.add(msg.id);
deduped.push(msg);
}
deduped.sort(compareMessagesByTimestamp);
return deduped;
}
/**
@@ -141,28 +250,59 @@ const MAX_REALTIME_MESSAGES = 500;
export function useSessionStore() {
const storeRef = useRef(new Map<string, SessionSlot>());
const sessionAliasesRef = useRef(new Map<string, string>());
const activeSessionIdRef = useRef<string | null>(null);
// Bump to force re-render — only when the active session's data changes
const [, setTick] = useState(0);
const notify = useCallback((sessionId: string) => {
if (sessionId === activeSessionIdRef.current) {
const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
if (resolvedSessionId === activeSessionIdRef.current) {
setTick(n => n + 1);
}
}, []);
const setActiveSession = useCallback((sessionId: string | null) => {
activeSessionIdRef.current = sessionId;
const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => {
if (!sessionId) {
return null;
}
const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
return resolvedSessionId;
}, []);
const setActiveSession = useCallback((sessionId: string | null) => {
activeSessionIdRef.current = resolveSessionId(sessionId);
}, [resolveSessionId]);
const getSlot = useCallback((sessionId: string): SessionSlot => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const store = storeRef.current;
if (!store.has(sessionId)) {
store.set(sessionId, createEmptySlot());
if (!store.has(resolvedSessionId)) {
store.set(resolvedSessionId, createEmptySlot());
}
return store.get(sessionId)!;
}, []);
return store.get(resolvedSessionId)!;
}, [resolveSessionId]);
const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []);
const has = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.has(resolvedSessionId);
}, [resolveSessionId]);
/**
* Fetch messages from the provider sessions endpoint and populate serverMessages.
@@ -179,9 +319,10 @@ export function useSessionStore() {
offset?: number;
} = {},
) => {
const slot = getSlot(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
slot.status = 'loading';
notify(sessionId);
notify(resolvedSessionId);
try {
const params = new URLSearchParams();
@@ -191,7 +332,7 @@ export function useSessionStore() {
}
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) {
@@ -212,15 +353,15 @@ export function useSessionStore() {
slot.tokenUsage = data.tokenUsage;
}
notify(sessionId);
notify(resolvedSessionId);
return slot;
} catch (error) {
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error);
slot.status = 'error';
notify(sessionId);
notify(resolvedSessionId);
return slot;
}
}, [getSlot, notify]);
}, [getSlot, notify, resolveSessionId]);
/**
* Load older (paginated) messages and prepend to serverMessages.
@@ -234,7 +375,8 @@ export function useSessionStore() {
limit?: number;
} = {},
) => {
const slot = getSlot(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
if (!slot.hasMore) return slot;
const params = new URLSearchParams();
@@ -243,7 +385,7 @@ export function useSessionStore() {
params.append('offset', String(slot.offset));
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
try {
const response = await authenticatedFetch(url);
@@ -256,43 +398,54 @@ export function useSessionStore() {
slot.hasMore = Boolean(data.hasMore);
slot.offset = slot.offset + olderMessages.length;
recomputeMergedIfNeeded(slot);
notify(sessionId);
notify(resolvedSessionId);
return slot;
} catch (error) {
console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error);
console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error);
return slot;
}
}, [getSlot, notify]);
}, [getSlot, notify, resolveSessionId]);
/**
* Append a realtime (WebSocket) message to the correct session slot.
* This works regardless of which session is actively viewed.
*/
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
const slot = getSlot(sessionId);
let updated = [...slot.realtimeMessages, msg];
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const normalizedMessage =
msg.sessionId === resolvedSessionId
? msg
: { ...msg, sessionId: resolvedSessionId };
let updated = [...slot.realtimeMessages, normalizedMessage];
if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES);
}
slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot);
notify(sessionId);
}, [getSlot, notify]);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
/**
* Append multiple realtime messages at once (batch).
*/
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
if (msgs.length === 0) return;
const slot = getSlot(sessionId);
let updated = [...slot.realtimeMessages, ...msgs];
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const normalizedMessages = msgs.map((msg) =>
msg.sessionId === resolvedSessionId
? msg
: { ...msg, sessionId: resolvedSessionId },
);
let updated = [...slot.realtimeMessages, ...normalizedMessages];
if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES);
}
slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot);
notify(sessionId);
}, [getSlot, notify]);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
/**
* Re-fetch serverMessages from the provider sessions endpoint.
@@ -305,12 +458,13 @@ export function useSessionStore() {
projectPath?: string;
} = {},
) => {
const slot = getSlot(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
try {
const params = new URLSearchParams();
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -323,40 +477,43 @@ export function useSessionStore() {
// drop realtime messages that the server has caught up with to prevent unbounded growth.
slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot);
notify(sessionId);
notify(resolvedSessionId);
} catch (error) {
console.error(`[SessionStore] refresh failed for ${sessionId}:`, error);
console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error);
}
}, [getSlot, notify]);
}, [getSlot, notify, resolveSessionId]);
/**
* Update session status.
*/
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
const slot = getSlot(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
slot.status = status;
notify(sessionId);
}, [getSlot, notify]);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
/**
* Check if a session's data is stale (>30s old).
*/
const isStale = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (!slot) return true;
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
}, []);
}, [resolveSessionId]);
/**
* Update or create a streaming message (accumulated text so far).
* Uses a well-known ID so subsequent calls replace the same message.
*/
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => {
const slot = getSlot(sessionId);
const streamId = `__streaming_${sessionId}`;
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const streamId = `__streaming_${resolvedSessionId}`;
const msg: NormalizedMessage = {
id: streamId,
sessionId,
sessionId: resolvedSessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,
kind: 'stream_delta',
@@ -370,17 +527,18 @@ export function useSessionStore() {
slot.realtimeMessages = [...slot.realtimeMessages, msg];
}
recomputeMergedIfNeeded(slot);
notify(sessionId);
}, [getSlot, notify]);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
/**
* Finalize streaming: convert the streaming message to a regular text message.
* The well-known streaming ID is replaced with a unique text message ID.
*/
const finalizeStreaming = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (!slot) return;
const streamId = `__streaming_${sessionId}`;
const streamId = `__streaming_${resolvedSessionId}`;
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
if (idx >= 0) {
const stream = slot.realtimeMessages[idx];
@@ -392,35 +550,104 @@ export function useSessionStore() {
role: 'assistant',
};
recomputeMergedIfNeeded(slot);
notify(sessionId);
notify(resolvedSessionId);
}
}, [notify]);
}, [notify, resolveSessionId]);
/**
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
*/
const clearRealtime = useCallback((sessionId: string) => {
const slot = storeRef.current.get(sessionId);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
if (slot) {
slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot);
notify(sessionId);
notify(resolvedSessionId);
}
}, [notify]);
}, [notify, resolveSessionId]);
/**
* Get merged messages for a session (for rendering).
*/
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
return storeRef.current.get(sessionId)?.merged ?? [];
}, []);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.get(resolvedSessionId)?.merged ?? [];
}, [resolveSessionId]);
/**
* Get session slot (for status, pagination info, etc.).
*/
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
return storeRef.current.get(sessionId);
}, []);
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.get(resolvedSessionId);
}, [resolveSessionId]);
const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => {
const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId;
const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId;
if (resolvedFromSessionId === resolvedToSessionId) {
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
return;
}
const store = storeRef.current;
const sourceSlot = store.get(resolvedFromSessionId);
const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot();
if (sourceSlot) {
const migratedServerMessages = sourceSlot.serverMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages);
targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages);
if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) {
targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES);
}
targetSlot.status =
sourceSlot.status === 'error'
? 'error'
: sourceSlot.status === 'streaming' || targetSlot.status === 'streaming'
? 'streaming'
: sourceSlot.status === 'loading' || targetSlot.status === 'loading'
? 'loading'
: targetSlot.status;
targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now());
targetSlot.total = Math.max(
targetSlot.total,
sourceSlot.total,
targetSlot.serverMessages.length,
targetSlot.realtimeMessages.length,
);
targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore;
targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset);
targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage;
recomputeMergedIfNeeded(targetSlot);
store.set(resolvedToSessionId, targetSlot);
store.delete(resolvedFromSessionId);
}
sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId);
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) {
if (targetSessionId === resolvedFromSessionId) {
sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId);
}
}
if (activeSessionIdRef.current === resolvedFromSessionId) {
activeSessionIdRef.current = resolvedToSessionId;
}
notify(resolvedToSessionId);
}, [notify, resolveSessionId]);
return useMemo(() => ({
getSlot,
@@ -438,11 +665,12 @@ export function useSessionStore() {
clearRealtime,
getMessages,
getSessionSlot,
replaceSessionId,
}), [
getSlot, has, fetchFromServer, fetchMore,
appendRealtime, appendRealtimeBatch, refreshFromServer,
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
clearRealtime, getMessages, getSessionSlot,
clearRealtime, getMessages, getSessionSlot, replaceSessionId,
]);
}

View File

@@ -3,8 +3,6 @@ export {};
declare global {
interface Window {
__ROUTER_BASENAME__?: string;
refreshProjects?: () => void | Promise<void>;
openSettings?: (tab?: string) => void;
}
interface EventSourceEventMap {