Compare commits

...

22 Commits

Author SHA1 Message Date
simosmik
09dcea05fb fix: precise Claude SDK denial message detection in deriveToolStatus 2026-04-20 15:52:09 +00:00
simosmik
3969135bd4 fix: iOS scrolling main chat area 2026-04-20 15:47:19 +00:00
simosmik
25820ed995 fix: small mobile respnosive fixes 2026-04-20 15:41:37 +00:00
simosmik
fc3504eaed fix: migrate PlanDisplay raw params from native details to Collapsible primitive 2026-04-20 15:36:02 +00:00
simosmik
ec0ff974cb refactor: queue primitive, tool status badges, and tool display cleanup
- Add Queue/QueueItem/QueueItemIndicator/QueueItemContent primitive
- Rewrite TodoList using Queue (clean list, no bordered cards, no priority badges)
- Add ToolStatusBadge component (Running/Completed/Error/Denied)
- Migrate CollapsibleSection from native <details> to Collapsible primitive
- Add badge prop threading through CollapsibleDisplay and CollapsibleSection
- Add status badges to OneLineDisplay and CollapsibleDisplay via ToolRenderer
- Update SubagentContainer: theme tokens + Collapsible for tool history
- Replace hardcoded gray-* colors with theme tokens throughout tool displays
2026-04-20 15:30:16 +00:00
simosmik
c471b5d3fa fix: small mobile respnosive fixes 2026-04-20 15:05:08 +00:00
simosmik
5758bee8a0 refactor: chat composer new design 2026-04-20 14:47:49 +00:00
simosmik
7763e60fb3 refactor: add primitives, plan mode display, and new session model selector 2026-04-20 12:47:55 +00:00
viper151
25b00b58de chore(release): v1.29.5 2026-04-16 11:02:31 +00:00
simosmik
6a13e1773b fix: update node-pty to latest version 2026-04-16 10:52:55 +00:00
viper151
6102b74455 chore(release): v1.29.4 2026-04-16 10:33:45 +00:00
Simos Mikelatos
9ef1ab533d Refactor CLI authentication module location (#660)
* refactor: move cli-auth.js to the providers folder

* fix: expired oauth token returns no error message
2026-04-16 12:32:25 +02:00
simosmik
e9c7a5041c feat: deleting from sidebar will now ask whether to remove all data as well 2026-04-16 09:05:56 +00:00
simosmik
289520814c refactor: remove the sqlite3 dependency 2026-04-16 08:37:59 +00:00
simosmik
09486016e6 chore: upgrade commit lint to 20.5.0 2026-04-16 08:08:36 +00:00
simosmik
4c106a5083 fix: pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set
Closes #468
2026-04-16 07:58:32 +00:00
Simos Mikelatos
63e996bb77 refactor(server): extract URL detection and color utils from index.js (#657)
No behavioral changes — 1:1 code move with imports replacing inline definitions.
2026-04-16 09:46:09 +02:00
viper151
fbad3a90f8 chore(release): v1.29.3 2026-04-15 12:02:27 +00:00
Haile
96463df8da Feature/backend ts support andunification of auth settings on frontend (#654)
* fix: remove project dependency from settings controller and onboarding

* fix(settings): remove onClose prop from useSettingsController args

* chore: tailwind classes order

* refactor: move provider auth status management to custom hook

* refactor: rename SessionProvider to LLMProvider

* feat(frontend): support for @ alias based imports)

* fix: replace init.sql with schema.js

* fix: refactor database initialization to use schema.js for SQL statements

* feat(server): add a real backend TypeScript build and enforce module boundaries

The backend had started to grow beyond what the frontend-only tooling setup could
support safely. We were still running server code directly from /server, linting
mainly the client, and relying on path assumptions such as "../.." that only
worked in the source layout. That created three problems:

- backend alias imports were hard to resolve consistently in the editor, ESLint,
  and the runtime
- server code had no enforced module boundary rules, so cross-module deep imports
  could bypass intended public entry points
- building the backend into a separate output directory would break repo-level
  lookups for package.json, .env, dist, and public assets because those paths
  were derived from source-only relative assumptions

This change makes the backend tooling explicit and runtime-safe.

A dedicated backend TypeScript config now lives in server/tsconfig.json, with
tsconfig.server.json reduced to a compatibility shim. This gives the language
service and backend tooling a canonical project rooted in /server while still
preserving top-level compatibility for any existing references. The backend alias
mapping now resolves relative to /server, which avoids colliding with the
frontend's "@/..." -> "src/*" mapping.

The package scripts were updated so development runs through tsx with the backend
tsconfig, build now produces a compiled backend in dist-server, and typecheck/lint
cover both client and server. A new build-server.mjs script runs TypeScript and
tsc-alias and cleans dist-server first, which prevents stale compiled files from
shadowing current source files after refactors.

To make the compiled backend behave the same as the source backend, runtime path
resolution was centralized in server/utils/runtime-paths.js. Instead of assuming
fixed relative paths from each module, server entry points now resolve the actual
app root and server root at runtime. That keeps package.json, .env, dist, public,
and default database paths stable whether code is executed from /server or from
/dist-server/server.

ESLint was expanded from a frontend-only setup into a backend-aware one. The
backend now uses import resolution tied to the backend tsconfig so aliased imports
resolve correctly in linting, import ordering matches the frontend style, and
unused/duplicate imports are surfaced consistently.

Most importantly, eslint-plugin-boundaries now enforces server module boundaries.
Files under server/modules can no longer import another module's internals
directly. Cross-module imports must go through that module's barrel file
(index.ts/index.js). boundaries/no-unknown was also enabled so alias-resolution
gaps cannot silently bypass the rule.

Together, these changes make the backend buildable, keep runtime path resolution
stable after compilation, align server tooling with the client where appropriate,
and enforce a stricter modular architecture for server code.

* fix: update package.json to include dist-server in files and remove tsconfig.server.json

* refactor: remove build-server.mjs and inline its logic into package.json scripts

* fix: update paths in package.json and bin.js to use dist-server directory

* feat(eslint): add backend shared types and enforce compile-time contract for imports

* fix(eslint): update shared types pattern

---------

Co-authored-by: Haileyesus <something@gmail.com>
2026-04-15 13:26:12 +02:00
simosmik
31f28a2c18 chore: remove unused route (migrated to providers already) 2026-04-14 21:58:53 +00:00
Simos Mikelatos
8ff5f35c05 Update model constants for Opus and Gemini versions 2026-04-14 23:06:43 +02:00
Haile
641304242d fix(version-upgrade-modal): implement reload countdown and update UI messages (#655)
Co-authored-by: Haileyesus <something@gmail.com>
2026-04-14 23:02:20 +02:00
112 changed files with 4917 additions and 3132 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ lerna-debug.log*
# Build outputs
dist/
dist-server/
dist-ssr/
build/
out/
@@ -138,4 +139,4 @@ tasks/
!src/i18n/locales/de/tasks.json
# Git worktrees
.worktrees/
.worktrees/

View File

@@ -3,6 +3,41 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
### Bug Fixes
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes

View File

@@ -3,7 +3,9 @@ import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x";
import { createNodeResolver, importX } from "eslint-plugin-import-x";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import boundaries from "eslint-plugin-boundaries";
import tailwindcss from "eslint-plugin-tailwindcss";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
@@ -82,7 +84,7 @@ export default tseslint.config(
"sibling",
"index",
],
"newlines-between": "never",
"newlines-between": "always",
},
],
@@ -98,5 +100,131 @@ export default tseslint.config(
"no-control-regex": "off",
"no-useless-escape": "off",
},
},
{
files: ["server/**/*.{js,ts}"], // apply this block only to backend source files
ignores: ["server/**/*.d.ts"], // skip generated declaration files in backend linting
plugins: {
boundaries, // enforce backend architecture boundaries (module-to-module contracts)
"import-x": importX, // keep import hygiene rules (duplicates, unresolved paths, etc.)
"unused-imports": unusedImports, // remove dead imports/variables from backend files
},
languageOptions: {
parser: tseslint.parser, // parse both JS and TS syntax in backend files
parserOptions: {
ecmaVersion: "latest", // support modern ECMAScript syntax in backend code
sourceType: "module", // treat backend files as ESM modules
},
globals: {
...globals.node, // expose Node.js globals such as process, Buffer, and __dirname equivalents
},
},
settings: {
"boundaries/include": ["server/**/*.{js,ts}"], // only analyze dependency boundaries inside backend files
"import/resolver": {
// boundaries resolves imports through eslint-module-utils, which reads the classic
// import/resolver setting instead of import-x/resolver-next.
typescript: {
project: ["server/tsconfig.json"], // resolve backend aliases using the canonical backend tsconfig
alwaysTryTypes: true, // keep normal TS package/type resolution working alongside aliases
},
node: {
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"], // preserve Node-style fallback resolution for plain files
},
},
"import-x/resolver-next": [
// ESLint's import plugin does not read tsconfig path aliases on its own.
// This resolver teaches import-x how to understand the backend-only "@/*"
// mapping defined in server/tsconfig.json, which fixes false no-unresolved errors in editors.
createTypeScriptImportResolver({
project: ["server/tsconfig.json"], // point the resolver at the canonical backend tsconfig instead of the frontend one
alwaysTryTypes: true, // keep standard TypeScript package resolution working while backend aliases are enabled
}),
// Keep Node-style resolution available for normal package imports and plain relative JS files.
// The TypeScript resolver handles aliases, while the Node resolver preserves the expected fallback behavior.
createNodeResolver({
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"],
}),
],
"boundaries/elements": [
{
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
},
{
type: "backend-module", // logical element name used by boundaries rules below
pattern: "server/modules/*", // each direct folder in server/modules is treated as one module boundary
mode: "folder", // classify dependencies at folder-module level (not per individual file)
capture: ["moduleName"], // capture the module folder name for messages/debugging/template use
},
],
},
rules: {
// --- Unused imports/vars (backend) ---
"unused-imports/no-unused-imports": "warn", // warn when imports are not used so they can be cleaned up
"unused-imports/no-unused-vars": "off", // keep backend signal focused on dead imports instead of local unused variables
// --- Import hygiene (backend) ---
"import-x/no-duplicates": "warn", // prevent duplicate import lines from the same module
"import-x/order": [
"warn", // keep backend import grouping/order consistent with the frontend config
{
groups: [
"builtin", // Node built-ins such as fs, path, and url come first
"external", // third-party packages come after built-ins
"internal", // aliased internal imports such as @/... come next
"parent", // ../ imports come after aliased internal imports
"sibling", // ./foo imports come after parent imports
"index", // bare ./ imports stay last
],
"newlines-between": "always", // require a blank line between import groups in backend files too
},
],
"import-x/no-unresolved": "error", // fail when an import path cannot be resolved
"import-x/no-useless-path-segments": "warn", // prefer cleaner paths (remove redundant ./ and ../ segments)
"import-x/no-absolute-path": "error", // disallow absolute filesystem imports in backend files
// --- General safety/style (backend) ---
eqeqeq: ["warn", "always", { null: "ignore" }], // avoid accidental coercion while still allowing x == null checks
// --- Architecture boundaries (backend modules) ---
"boundaries/dependencies": [
"error", // treat architecture violations as lint errors
{
default: "allow", // allow normal imports unless a rule below explicitly disallows them
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
rules: [
{
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
to: { type: "backend-shared-types" },
disallow: {
dependency: { kind: ["value", "typeof"] },
}, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module
message:
"Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).",
},
{
to: { type: "backend-module" }, // when importing anything that belongs to another backend module
disallow: { to: { internalPath: "**" } }, // block all direct/deep imports into module internals by default
message:
"Cross-module imports must go through that module's barrel file (server/modules/<module>/index.ts or index.js).", // explicit error message for architecture violations
},
{
to: { type: "backend-module" }, // same target scope as the disallow rule above
allow: {
to: {
internalPath: [
"index", // allow extensionless barrel imports resolved as module root index
"index.{js,mjs,cjs,ts,tsx}", // allow explicit index.* barrel file imports
],
},
}, // re-allow only public module entry points (barrel files)
},
],
},
],
"boundaries/no-unknown": "error", // fail fast if boundaries cannot classify a dependency, which prevents silent rule bypasses
},
}
);

1895
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.29.2",
"version": "1.29.5",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
"main": "dist-server/server/index.js",
"bin": {
"cloudcli": "server/cli.js"
"cloudcli": "dist-server/server/cli.js"
},
"files": [
"server/",
"shared/",
"dist/",
"dist-server/",
"scripts/",
"README.md"
],
@@ -23,14 +24,19 @@
"url": "https://github.com/siteboon/claudecodeui/issues"
},
"scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
"server": "node server/index.js",
"dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
"server": "node dist-server/server/index.js",
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite",
"build": "vite build",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
"lint": "eslint src/ server/",
"lint:fix": "eslint src/ server/ --fix",
"start": "npm run build && npm run server",
"release": "./release.sh",
"prepublishOnly": "npm run build",
@@ -98,7 +104,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.1.0-beta34",
"node-pty": "^1.2.0-beta.12",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -111,15 +117,13 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7",
@@ -130,6 +134,8 @@
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -144,11 +150,14 @@
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.0.4"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint"
"src/**/*.{ts,tsx,js,jsx}": "eslint",
"server/**/*.{js,ts}": "eslint"
}
}

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env node
import('@cloudcli-ai/cloudcli/server/cli.js');
import('@cloudcli-ai/cloudcli/dist-server/server/cli.js');

View File

@@ -26,13 +26,14 @@ import {
} from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
@@ -148,6 +149,10 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
if (process.env.CLAUDE_CLI_PATH) {
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
}
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
@@ -701,8 +706,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -710,8 +721,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary,
error
});
throw error;
}
}

View File

@@ -16,11 +16,12 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// The CLI is compiled into dist-server/server, but it still needs to read the top-level
// package.json and .env file. Resolving the app root once keeps those lookups stable.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output
const colors = {
@@ -50,13 +51,16 @@ const c = {
};
// Load package.json for version info
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJsonPath = path.join(APP_ROOT, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Match the runtime fallback in load-env.js so "cloudcli status" reports the same default
// database location that the backend will actually use when no DATABASE_PATH is configured.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
// Load environment variables from .env file if it exists
function loadEnvFile() {
try {
const envPath = path.join(__dirname, '../.env');
const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
@@ -75,12 +79,12 @@ function loadEnvFile() {
// Get the database path (same logic as db.js)
function getDatabasePath() {
loadEnvFile();
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');
return process.env.DATABASE_PATH || DEFAULT_DATABASE_PATH;
}
// Get the installation directory
function getInstallDir() {
return path.join(__dirname, '..');
return APP_ROOT;
}
// Show status command
@@ -124,7 +128,7 @@ function showStatus() {
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
// Config file location
const envFilePath = path.join(__dirname, '../.env');
const envFilePath = path.join(APP_ROOT, '.env');
const envExists = fs.existsSync(envFilePath);
console.log(`\n${c.info('[INFO]')} Configuration File:`);
console.log(` ${c.dim(envFilePath)}`);

View File

@@ -3,6 +3,7 @@ import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -294,7 +295,13 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
// Check if Cursor CLI is installed for a clearer error message
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
notifyTerminalState({ error });
settleOnce(() => reject(error));

View File

@@ -2,11 +2,21 @@ import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
import {
APP_CONFIG_TABLE_SQL,
USER_NOTIFICATION_PREFERENCES_TABLE_SQL,
VAPID_KEYS_TABLE_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SQL,
SESSION_NAMES_TABLE_SQL,
SESSION_NAMES_LOOKUP_INDEX_SQL,
DATABASE_SCHEMA_SQL
} from './schema.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// The compiled backend lives under dist-server/server/database, but the install root we log
// should still point at the project/app root. Resolving it here avoids build-layout drift.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output
const colors = {
@@ -24,7 +34,6 @@ const c = {
// Use DATABASE_PATH environment variable if set, otherwise use default location
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Ensure database directory exists if custom path is provided
if (process.env.DATABASE_PATH) {
@@ -62,14 +71,10 @@ const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec(APP_CONFIG_TABLE_SQL);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
const appInstallPath = APP_ROOT;
console.log('');
console.log(c.dim('═'.repeat(60)));
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
@@ -100,53 +105,12 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SQL);
db.exec(VAPID_KEYS_TABLE_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SQL);
db.exec(APP_CONFIG_TABLE_SQL);
db.exec(SESSION_NAMES_TABLE_SQL);
db.exec(SESSION_NAMES_LOOKUP_INDEX_SQL);
console.log('Database migrations completed successfully');
} catch (error) {
@@ -158,8 +122,7 @@ const runMigrations = () => {
// Initialize database with schema
const initializeDatabase = async () => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL);
db.exec(DATABASE_SCHEMA_SQL);
console.log('Database initialized successfully');
runMigrations();
} catch (error) {

View File

@@ -1,99 +0,0 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
-- API Keys table for external API access
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Session custom names (provider-agnostic display name overrides)
CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

102
server/database/schema.js Normal file
View File

@@ -0,0 +1,102 @@
export const APP_CONFIG_TABLE_SQL = `CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const USER_NOTIFICATION_PREFERENCES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const VAPID_KEYS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`;
export const PUSH_SUBSCRIPTIONS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`;
export const SESSION_NAMES_TABLE_SQL = `CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);`;
export const SESSION_NAMES_LOOKUP_INDEX_SQL = `CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);`;
export const DATABASE_SCHEMA_SQL = `PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL,
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
${USER_NOTIFICATION_PREFERENCES_TABLE_SQL}
${VAPID_KEYS_TABLE_SQL}
${PUSH_SUBSCRIPTIONS_TABLE_SQL}
${SESSION_NAMES_TABLE_SQL}
${SESSION_NAMES_LOOKUP_INDEX_SQL}
${APP_CONFIG_TABLE_SQL}
`;

View File

@@ -10,6 +10,7 @@ import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -380,6 +381,15 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
@@ -394,8 +404,14 @@ async function spawnGemini(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });
reject(error);

View File

@@ -3,33 +3,15 @@
import './load-env.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// The server source runs from /server, while the compiled output runs from /dist-server/server.
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
import { c } from './utils/colors.js';
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -226,68 +208,7 @@ const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
@@ -405,11 +326,11 @@ app.use('/api/sessions', authenticateToken, messagesRoutes);
app.use('/api/agent', agentRoutes);
// Serve public files (like api-docs.html)
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.static(path.join(APP_ROOT, 'public')));
// Static files served after API routes
// Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), {
app.use(express.static(path.join(APP_ROOT, 'dist'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
// Prevent HTML caching to avoid service worker issues after builds
@@ -431,7 +352,7 @@ app.use(express.static(path.join(__dirname, '../dist'), {
app.post('/api/system/update', authenticateToken, async (req, res) => {
try {
// Get the project root directory (parent of server directory)
const projectRoot = path.join(__dirname, '..');
const projectRoot = APP_ROOT;
console.log('Starting system update from directory:', projectRoot);
@@ -573,12 +494,15 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
}
});
// Delete project endpoint (force=true to delete with sessions)
// Delete project endpoint
// force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const force = req.query.force === 'true';
await deleteProject(projectName, force);
const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -2273,7 +2197,7 @@ app.get('*', (req, res) => {
// Only serve index.html for HTML routes, not for static assets
// Static assets should already be handled by express.static middleware above
const indexPath = path.join(__dirname, '../dist/index.html');
const indexPath = path.join(APP_ROOT, 'dist', 'index.html');
// Check if dist/index.html exists (production build available)
if (fs.existsSync(indexPath)) {
@@ -2388,7 +2312,7 @@ async function startServer() {
configureWebPush();
// Check if running in production mode (dist folder exists)
const distIndexPath = path.join(__dirname, '../dist/index.html');
const distIndexPath = path.join(APP_ROOT, 'dist', 'index.html');
const isProduction = fs.existsSync(distIndexPath);
// Log Claude implementation mode
@@ -2402,7 +2326,7 @@ async function startServer() {
console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);
server.listen(SERVER_PORT, HOST, async () => {
const appInstallPath = path.join(__dirname, '..');
const appInstallPath = APP_ROOT;
console.log('');
console.log(c.dim('═'.repeat(63)));

View File

@@ -2,14 +2,15 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// Resolve the repo/app root via the nearest /server folder so this file keeps finding the
// same top-level .env file from both /server/load-env.js and /dist-server/server/load-env.js.
const APP_ROOT = findAppRoot(__dirname);
try {
const envPath = path.join(__dirname, '../.env');
const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
@@ -24,6 +25,10 @@ try {
console.log('No .env file found or error reading it:', e.message);
}
// Keep the default database in a stable user-level location so rebuilding dist-server
// never changes where the backend stores auth.db when DATABASE_PATH is not set explicitly.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
process.env.DATABASE_PATH = DEFAULT_DATABASE_PATH;
}

View File

@@ -17,6 +17,7 @@ import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { codexAdapter } from './providers/codex/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
// Track active sessions
const activeCodexSessions = new Map();
@@ -308,7 +309,14 @@ export async function queryCodex(command, options = {}, ws) {
if (!wasAborted) {
console.error('[Codex] Error:', error);
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
// Check if Codex SDK is available for a clearer error message
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

@@ -62,8 +62,7 @@ import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import Database from 'better-sqlite3';
import os from 'os';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
@@ -1164,8 +1163,9 @@ async function isProjectEmpty(projectName) {
}
}
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
// Remove a project from the UI.
// When deleteData=true, also delete session/memory files on disk (destructive).
async function deleteProject(projectName, force = false, deleteData = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
@@ -1175,48 +1175,50 @@ async function deleteProject(projectName, force = false) {
}
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Destructive path: delete underlying data when explicitly requested
if (deleteData) {
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Remove the Claude project directory (session logs, memory, subagent data)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
// Delete Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
}
// Remove from project config
// Always remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
console.error(`Error removing project ${projectName}:`, error);
throw error;
}
}
@@ -1305,16 +1307,10 @@ async function getCursorSessions(projectPath) {
} catch (_) { }
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
const metaRows = db.prepare('SELECT key, value FROM meta').all();
// Parse metadata
let metadata = {};
@@ -1336,11 +1332,9 @@ async function getCursorSessions(projectPath) {
}
// Get message count
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
await db.close();
db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';

View File

@@ -0,0 +1,136 @@
/**
* Claude Provider Status
*
* Checks whether Claude Code CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/claude/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Claude Code CLI is installed and available.
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed'
};
}
const credentialsResult = await checkCredentials();
if (credentialsResult.authenticated) {
return {
installed,
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method || null,
error: null
};
}
return {
installed,
authenticated: false,
email: credentialsResult.email || null,
method: credentialsResult.method || null,
error: credentialsResult.error || 'Not authenticated'
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function loadSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch {
// Ignore missing or malformed settings.
}
return {};
}
/**
* Checks Claude authentication credentials.
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*/
async function checkCredentials() {
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await loadSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
return {
authenticated: false,
email: creds.email || creds.user || null,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login'
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}

View File

@@ -0,0 +1,78 @@
/**
* Codex Provider Status
*
* Checks whether the user has valid Codex authentication credentials.
* Codex uses an SDK that makes direct API calls (no external binary),
* so installation check always returns true if the server is running.
*
* @module providers/codex/status
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Codex is installed.
* Codex SDK is bundled with this application — no external binary needed.
* @returns {boolean}
*/
export function checkInstalled() {
return true;
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
const tokens = auth.tokens || {};
if (tokens.id_token || tokens.access_token) {
let email = 'Authenticated';
if (tokens.id_token) {
try {
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
email = 'Authenticated';
}
}
return { authenticated: true, email };
}
if (auth.OPENAI_API_KEY) {
return { authenticated: true, email: 'API Key Auth' };
}
return { authenticated: false, email: null, error: 'No valid tokens found' };
} catch (error) {
if (error.code === 'ENOENT') {
return { authenticated: false, email: null, error: 'Codex not configured' };
}
return { authenticated: false, email: null, error: error.message };
}
}

View File

@@ -20,21 +20,16 @@ const PROVIDER = 'cursor';
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/
async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
const { default: sqlite3 } = await import('sqlite3');
const { open } = await import('sqlite');
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY,
});
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map();
const parentRefs = new Map();
@@ -129,7 +124,7 @@ async function loadCursorBlobs(sessionId, projectPath) {
return messages;
} finally {
await db.close();
db.close();
}
}

View File

@@ -0,0 +1,128 @@
/**
* Cursor Provider Status
*
* Checks whether cursor-agent CLI is installed and whether the user
* is logged in.
*
* @module providers/cursor/status
*/
import { execFileSync, spawn } from 'child_process';
/**
* Check if cursor-agent CLI is installed.
* @returns {boolean}
*/
export function checkInstalled() {
try {
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Cursor CLI is not installed'
};
}
const result = await checkCursorLogin();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function checkCursorLogin() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({ authenticated: true, email: emailMatch[1] });
} else if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in' });
} else {
resolve({ authenticated: false, email: null, error: 'Not logged in' });
}
} else {
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
}
});
childProcess.on('error', () => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}

View File

@@ -0,0 +1,111 @@
/**
* Gemini Provider Status
*
* Checks whether Gemini CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/gemini/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Gemini CLI is installed.
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Gemini CLI is not installed'
};
}
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
email = await getActiveAccountEmail() || email;
}
} catch {
// Network error, fallback to checking local accounts file
email = await getActiveAccountEmail() || email;
}
return { authenticated: true, email };
}
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
} catch {
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
}
}
async function getActiveAccountEmail() {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
return accounts.active || null;
} catch {
return null;
}
}

View File

@@ -1,8 +1,9 @@
/**
* Provider Registry
*
* Centralizes provider adapter lookup. All code that needs a provider adapter
* should go through this registry instead of importing individual adapters directly.
* Centralizes provider adapter and status checker lookup. All code that needs
* a provider adapter or status checker should go through this registry instead
* of importing individual modules directly.
*
* @module providers/registry
*/
@@ -12,6 +13,11 @@ import { cursorAdapter } from './cursor/adapter.js';
import { codexAdapter } from './codex/adapter.js';
import { geminiAdapter } from './gemini/adapter.js';
import * as claudeStatus from './claude/status.js';
import * as cursorStatus from './cursor/status.js';
import * as codexStatus from './codex/status.js';
import * as geminiStatus from './gemini/status.js';
/**
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
* @typedef {import('./types.js').SessionProvider} SessionProvider
@@ -20,12 +26,20 @@ import { geminiAdapter } from './gemini/adapter.js';
/** @type {Map<string, ProviderAdapter>} */
const providers = new Map();
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
const statusCheckers = new Map();
// Register built-in providers
providers.set('claude', claudeAdapter);
providers.set('cursor', cursorAdapter);
providers.set('codex', codexAdapter);
providers.set('gemini', geminiAdapter);
statusCheckers.set('claude', claudeStatus);
statusCheckers.set('cursor', cursorStatus);
statusCheckers.set('codex', codexStatus);
statusCheckers.set('gemini', geminiStatus);
/**
* Get a provider adapter by name.
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
@@ -35,6 +49,15 @@ export function getProvider(name) {
return providers.get(name);
}
/**
* Get a provider status checker by name.
* @param {string} name - Provider name
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
*/
export function getStatusChecker(name) {
return statusCheckers.get(name);
}
/**
* Get all registered provider names.
* @returns {string[]}

View File

@@ -69,6 +69,19 @@
* @property {object} [tokenUsage] - Token usage data (provider-specific)
*/
// ─── Provider Status ────────────────────────────────────────────────────────
/**
* Result of a provider status check (installation + authentication).
*
* @typedef {Object} ProviderStatus
* @property {boolean} installed - Whether the provider's CLI/SDK is available
* @property {boolean} authenticated - Whether valid credentials exist
* @property {string|null} email - User email or auth method identifier
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
* @property {string|null} [error] - Error message if not installed or not authenticated
*/
// ─── Provider Adapter Interface ──────────────────────────────────────────────
/**

View File

@@ -1,434 +1,27 @@
/**
* CLI Auth Routes
*
* Thin router that delegates to per-provider status checkers
* registered in the provider registry.
*
* @module routes/cli-auth
*/
import express from 'express';
import { spawn } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
const router = express.Router();
router.get('/claude/status', async (req, res) => {
try {
const credentialsResult = await checkClaudeCredentials();
if (credentialsResult.authenticated) {
return res.json({
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method // 'api_key' or 'credentials_file'
});
}
return res.json({
authenticated: false,
email: null,
method: null,
error: credentialsResult.error || 'Not authenticated'
});
} catch (error) {
console.error('Error checking Claude auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
method: null,
error: error.message
});
}
});
router.get('/cursor/status', async (req, res) => {
try {
const result = await checkCursorStatus();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Cursor auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/codex/status', async (req, res) => {
try {
const result = await checkCodexCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Codex auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
router.get('/gemini/status', async (req, res) => {
try {
const result = await checkGeminiCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Gemini auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
async function loadClaudeSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch (error) {
// Ignore missing or malformed settings and fall back to other auth sources.
}
return {};
}
/**
* Checks Claude authentication credentials using two methods with priority order:
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
* This matching behavior ensures consistency with how the SDK authenticates.
*
* References:
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
* - https://platform.claude.com/docs/en/agent-sdk/overview
* SDK authentication documentation
*
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
* - authenticated: boolean indicating if valid credentials exist
* - email: user email or auth method identifier
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
// Priority 1b: Check ~/.claude/settings.json env values.
// Claude Code can read proxy/auth values from settings.json even when the
// CloudCLI server process itself was not started with those env vars exported.
const settingsEnv = await loadClaudeSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth',
method: 'api_key'
};
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return {
authenticated: true,
email: 'Configured via settings.json',
method: 'api_key'
};
}
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
// This is the standard authentication method used by Claude CLI after running
// 'claude /login' or 'claude setup-token' commands.
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
}
return {
authenticated: false,
email: null,
method: null
};
} catch (error) {
return {
authenticated: false,
email: null,
method: null
};
}
}
function checkCursorStatus() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
for (const provider of getAllProviders()) {
router.get(`/${provider}/status`, async (req, res) => {
try {
childProcess = spawn('cursor-agent', ['status']);
} catch (err) {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
const checker = getStatusChecker(provider);
res.json(await checker.checkStatus());
} catch (error) {
console.error(`Error checking ${provider} status:`, error);
res.status(500).json({ authenticated: false, error: error.message });
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({
authenticated: true,
email: emailMatch[1],
output: stdout
});
} else if (stdout.includes('Logged in')) {
resolve({
authenticated: true,
email: 'Logged in',
output: stdout
});
} else {
resolve({
authenticated: false,
email: null,
error: 'Not logged in'
});
}
} else {
resolve({
authenticated: false,
email: null,
error: stderr || 'Not logged in'
});
}
});
childProcess.on('error', (err) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}
async function checkCodexCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
// Tokens are nested under 'tokens' key
const tokens = auth.tokens || {};
// Check for valid tokens (id_token or access_token)
if (tokens.id_token || tokens.access_token) {
// Try to extract email from id_token JWT payload
let email = 'Authenticated';
if (tokens.id_token) {
try {
// JWT is base64url encoded: header.payload.signature
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
// Decode the payload (second part)
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
// If JWT decoding fails, use fallback
email = 'Authenticated';
}
}
return {
authenticated: true,
email
};
}
// Also check for OPENAI_API_KEY as fallback auth method
if (auth.OPENAI_API_KEY) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found'
};
} catch (error) {
if (error.code === 'ENOENT') {
return {
authenticated: false,
email: null,
error: 'Codex not configured'
};
}
return {
authenticated: false,
email: null,
error: error.message
};
}
}
async function checkGeminiCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
// Validate token against Google API
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
// Token invalid and no refresh token available
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (e) { }
}
} catch (e) {
// Network error, fallback to checking local accounts file
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (err) { }
}
return {
authenticated: true,
email: email
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found in oauth_creds'
};
} catch (error) {
return {
authenticated: false,
email: null,
error: 'Gemini CLI not configured'
};
}
}
export default router;

View File

@@ -1,13 +1,15 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { parseFrontmatter } from '../utils/frontmatter.js';
import { findAppRoot, getModuleDir } from '../utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// This route reads the top-level package.json for the status command, so it needs the real
// app root even after compilation moves the route file under dist-server/server/routes.
const APP_ROOT = findAppRoot(__dirname);
const router = express.Router();
@@ -291,7 +293,7 @@ Custom commands can be created in:
'/status': async (args, context) => {
// Read version from package.json
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
const packageJsonPath = path.join(APP_ROOT, 'package.json');
let version = 'unknown';
let packageName = 'claude-code-ui';

View File

@@ -2,9 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { spawn } from 'child_process';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js';
@@ -387,16 +385,10 @@ router.get('/sessions', async (req, res) => {
} catch (_) {}
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
const metaRows = db.prepare('SELECT key, value FROM meta').all();
let sessionData = {
id: sessionId,
@@ -458,20 +450,11 @@ router.get('/sessions', async (req, res) => {
// Get message count from JSON blobs only (actual messages, not DAG structure)
try {
const blobCount = await db.get(`
SELECT COUNT(*) as count
FROM blobs
WHERE substr(data, 1, 1) = X'7B'
`);
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = await db.get(`
SELECT data FROM blobs
WHERE substr(data, 1, 1) = X'7B'
ORDER BY rowid DESC
LIMIT 1
`);
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
if (lastBlob && lastBlob.data) {
try {
@@ -526,7 +509,7 @@ router.get('/sessions', async (req, res) => {
console.log('Could not read blobs:', e.message);
}
await db.close();
db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) {
@@ -578,221 +561,4 @@ router.get('/sessions', async (req, res) => {
});
}
});
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
router.get('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
const { projectPath } = req.query;
// Calculate cwdID hash for the project path
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
// Open SQLite database
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get all blobs to build the DAG structure
const allBlobs = await db.all(`
SELECT rowid, id, data FROM blobs
`);
// Build the DAG structure from parent-child relationships
const blobMap = new Map(); // id -> blob data
const parentRefs = new Map(); // blob id -> [parent blob ids]
const childRefs = new Map(); // blob id -> [child blob ids]
const jsonBlobs = []; // Clean JSON messages
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
// Check if this is a JSON blob (actual message) or protobuf (DAG structure)
if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch (e) {
console.log('Failed to parse JSON blob:', blob.rowid);
}
} else if (blob.data) { // Protobuf blob - extract parent references
const parents = [];
let i = 0;
// Scan for parent references (0x0A 0x20 followed by 32-byte hash)
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {
const parentHash = blob.data.slice(i+2, i+34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
// Update child references
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Perform topological sort to get chronological order
const visited = new Set();
const sorted = [];
// DFS-based topological sort
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
// Visit all parents first (dependencies)
const parents = parentRefs.get(nodeId) || [];
for (const parentId of parents) {
visit(parentId);
}
// Add this node after all its parents
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
}
// Start with nodes that have no parents (roots)
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
// Visit any remaining nodes (disconnected components)
for (const blob of allBlobs) {
visit(blob.id);
}
// Now extract JSON messages in the order they appear in the sorted DAG
const messageOrder = new Map(); // JSON blob id -> order index
let orderIndex = 0;
for (const blob of sorted) {
// Check if this blob references any JSON messages
if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob
// Look for JSON blob references
for (const jsonBlob of jsonBlobs) {
try {
const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(jsonIdBytes)) {
if (!messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
}
} catch (e) {
// Skip if can't convert ID
}
}
}
}
// Sort JSON blobs by their appearance order in the DAG
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
// Fallback to rowid if not in order map
return a.rowid - b.rowid;
});
// Use sorted JSON blobs
const blobs = sortedJsonBlobs.map((blob, idx) => ({
...blob,
sequence_num: idx + 1,
original_rowid: blob.rowid
}));
// Get metadata from meta table
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
metadata[row.key] = JSON.parse(jsonStr);
} else {
metadata[row.key] = row.value.toString();
}
} catch (e) {
metadata[row.key] = row.value.toString();
}
}
}
// Extract messages from sorted JSON blobs
const messages = [];
for (const blob of blobs) {
try {
// We already parsed JSON blobs earlier
const parsed = blob.parsed;
if (parsed) {
// Filter out ONLY system messages at the server level
// Check both direct role and nested message.role
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue; // Skip only system messages
}
messages.push({
id: blob.id,
sequence: blob.sequence_num,
rowid: blob.original_rowid,
content: parsed
});
}
} catch (e) {
// Skip blobs that cause errors
console.log(`Skipping blob ${blob.id}: ${e.message}`);
}
}
await db.close();
res.json({
success: true,
session: {
id: sessionId,
projectPath: projectPath,
messages: messages,
metadata: metadata,
cwdId: cwdId
}
});
} catch (error) {
console.error('Error reading Cursor session:', error);
res.status(500).json({
error: 'Failed to read Cursor session',
details: error.message
});
}
});
export default router;

33
server/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"baseUrl": ".",
"paths": {
// In the backend config, "@" maps to the /server directory itself.
"@/*": ["*"]
},
// The backend is still mostly JavaScript today, so allowJs lets us add a real
// TypeScript build without forcing a large rename before the tooling is usable.
"allowJs": true,
// Keep the migration incremental: existing JS keeps building, while any new TS files
// still go through the normal TypeScript pipeline and strict checks.
"checkJs": false,
"strict": true,
"noEmitOnError": true,
// The backend build emits both /server and /shared into dist-server, so rootDir must
// stay one level above this file even though the config itself now lives in /server.
"rootDir": "..",
"outDir": "../dist-server",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["./**/*.js", "./**/*.ts", "../shared/**/*.js", "../shared/**/*.ts"],
"exclude": ["../dist", "../dist-server", "../node_modules", "../src"]
}

21
server/utils/colors.js Normal file
View File

@@ -0,0 +1,21 @@
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
export { colors, c };

View File

@@ -0,0 +1,37 @@
import path from 'path';
import { fileURLToPath } from 'url';
export function getModuleDir(importMetaUrl) {
return path.dirname(fileURLToPath(importMetaUrl));
}
export function findServerRoot(startDir) {
// Source files live under /server, while compiled files live under /dist-server/server.
// Walking up to the nearest "server" folder gives every backend module one stable anchor
// that works in both layouts instead of relying on fragile "../.." assumptions.
let currentDir = startDir;
while (path.basename(currentDir) !== 'server') {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
throw new Error(`Could not resolve the backend server root from "${startDir}".`);
}
currentDir = parentDir;
}
return currentDir;
}
export function findAppRoot(startDir) {
const serverRoot = findServerRoot(startDir);
const parentOfServerRoot = path.dirname(serverRoot);
// Source files live at <app>/server, while compiled files live at <app>/dist-server/server.
// When the nearest server folder sits inside dist-server we need to hop one extra level up
// so repo-level files still resolve from the real app root instead of the build directory.
return path.basename(parentOfServerRoot) === 'dist-server'
? path.dirname(parentOfServerRoot)
: parentOfServerRoot;
}

View File

@@ -0,0 +1,71 @@
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
export {
ANSI_ESCAPE_SEQUENCE_REGEX,
TRAILING_URL_PUNCTUATION_REGEX,
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
shouldAutoOpenUrlFromOutput
};

View File

@@ -18,9 +18,10 @@ export const CLAUDE_MODELS = {
{ value: "haiku", label: "Haiku" },
{ value: "opusplan", label: "Opus Plan" },
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
{ value: "opus[1m]", label: "Opus [1M]" },
],
DEFAULT: "sonnet",
DEFAULT: "opus",
};
/**
@@ -58,6 +59,7 @@ export const CURSOR_MODELS = {
export const CODEX_MODELS = {
OPTIONS: [
{ value: "gpt-5.4", label: "GPT-5.4" },
{ value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ value: "gpt-5.2", label: "GPT-5.2" },
@@ -88,5 +90,5 @@ export const GEMINI_MODELS = {
},
],
DEFAULT: "gemini-2.5-flash",
DEFAULT: "gemini-3.1-pro-preview",
};

View File

@@ -122,8 +122,28 @@ export default function AppContent() {
}
}, [isConnected, selectedSession?.id, sendMessage]);
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
// so inset-0 adjusts automatically. On iOS the layout viewport stays full-height and
// the keyboard overlays it — we use the Visual Viewport API to track keyboard height
// and apply it as a CSS variable that shifts the container's bottom edge up.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
// Only resize matters — keyboard open/close changes vv.height.
// Do NOT listen to scroll: on iOS Safari, scrolling content changes
// vv.offsetTop which would make --keyboard-height fluctuate during
// normal scrolling, causing the container to bounce up and down.
const kb = Math.max(0, window.innerHeight - vv.height);
document.documentElement.style.setProperty('--keyboard-height', `${kb}px`);
};
vv.addEventListener('resize', update);
return () => vv.removeEventListener('resize', update);
}, []);
return (
<div className="fixed inset-0 flex bg-background">
<div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
{!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} />

View File

@@ -19,7 +19,7 @@ import type {
PendingPermissionRequest,
PermissionMode,
} from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
@@ -33,7 +33,7 @@ interface UseChatComposerStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
provider: LLMProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
cursorModel: string;
@@ -737,7 +737,7 @@ export function useChatComposerState({
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
@@ -824,7 +824,7 @@ export function useChatComposerState({
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
import type { ProjectSession, LLMProvider } from '../../../types/app';
interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null;
@@ -11,8 +11,8 @@ interface UseChatProviderStateArgs {
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<SessionProvider>(() => {
return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
const [provider, setProvider] = useState<LLMProvider>(() => {
return (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
});
const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import type { PendingPermissionRequest } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type PendingViewSession = {
@@ -48,7 +48,7 @@ type LatestChatMessage = {
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
provider: SessionProvider;
provider: LLMProvider;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
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';
@@ -40,7 +40,7 @@ interface ScrollRestoreState {
function chatMessageToNormalized(
msg: ChatMessage,
sessionId: string,
provider: SessionProvider,
provider: LLMProvider,
): NormalizedMessage | null {
const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const ts = msg.timestamp instanceof Date
@@ -151,7 +151,7 @@ export function useChatSessionState({
// 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) {
const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized);
@@ -189,7 +189,7 @@ export function useChatSessionState({
setPendingUserMessage(msg);
return;
}
const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
const normalized = chatMessageToNormalized(msg, activeSessionId, prov);
if (normalized) {
sessionStore.appendRealtime(activeSessionId, normalized);
@@ -240,7 +240,7 @@ export function useChatSessionState({
try {
const slot = await sessionStore.fetchMore(selectedSession.id, {
provider: sessionProvider as SessionProvider,
provider: sessionProvider as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
@@ -374,7 +374,7 @@ export function useChatSessionState({
// Fetch from server → store updates → chatMessages re-derives automatically
setIsLoadingSessionMessages(true);
sessionStore.fetchFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as SessionProvider,
provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: MESSAGES_PER_PAGE,
@@ -410,7 +410,7 @@ export function useChatSessionState({
// Skip store refresh during active streaming
if (!isLoading) {
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || provider) as SessionProvider,
provider: (selectedSession.__provider || provider) as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
@@ -468,7 +468,7 @@ export function useChatSessionState({
try {
// Load all messages into the store for search navigation
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
provider: sessionProvider as SessionProvider,
provider: sessionProvider as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,
@@ -655,7 +655,7 @@ export function useChatSessionState({
try {
const slot = await sessionStore.fetchFromServer(requestSessionId, {
provider: sessionProvider as SessionProvider,
provider: sessionProvider as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
limit: null,

View File

@@ -1,8 +1,13 @@
import React, { memo, useMemo, useCallback } from 'react';
import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import { PlanDisplay } from './components/PlanDisplay';
import { ToolStatusBadge } from './components/ToolStatusBadge';
import type { ToolStatus } from './components/ToolStatusBadge';
type DiffLine = {
type: string;
@@ -36,12 +41,32 @@ function getToolCategory(toolName: string): string {
if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'Task') return 'agent'; // Subagent task
if (toolName === 'Task') return 'agent';
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
if (toolName === 'AskUserQuestion') return 'question';
return 'default';
}
// Exact denial messages from server/claude-sdk.js — other providers can't reliably signal denial
const CLAUDE_DENIAL_MESSAGES = [
'user denied tool use',
'tool disallowed by settings',
'permission request timed out',
'permission request cancelled',
];
function deriveToolStatus(toolResult: any): ToolStatus {
if (!toolResult) return 'running';
if (toolResult.isError) {
const content = String(toolResult.content || '').toLowerCase().trim();
if (CLAUDE_DENIAL_MESSAGES.some((msg) => content.includes(msg))) {
return 'denied';
}
return 'error';
}
return 'completed';
}
/**
* Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
@@ -73,6 +98,12 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}, [mode, toolInput, toolResult]);
// Only derive and show status badge on input renders
const toolStatus = useMemo(
() => mode === 'input' ? deriveToolStatus(toolResult) : undefined,
[mode, toolResult],
);
const handleAction = useCallback(() => {
if (displayConfig?.action === 'open-file' && onFileOpen) {
const value = displayConfig.getValue?.(parsedData) || '';
@@ -82,9 +113,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
// Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
if (mode === 'result') return null;
return (
<SubagentContainer
toolInput={toolInput}
@@ -115,6 +144,34 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
wrapText={displayConfig.wrapText}
colorScheme={displayConfig.colorScheme}
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
status={toolStatus !== 'completed' ? toolStatus : undefined}
/>
);
}
if (displayConfig.type === 'plan') {
const title = typeof displayConfig.title === 'function'
? displayConfig.title(parsedData)
: displayConfig.title || 'Plan';
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,
createDiff,
onFileOpen
}) || {};
const isStreaming = mode === 'input' && !toolResult;
return (
<PlanDisplay
title={title}
content={contentProps.content || ''}
defaultOpen={displayConfig.defaultOpen ?? autoExpandTools}
isStreaming={isStreaming}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolName={toolName}
toolId={toolId}
/>
);
}
@@ -134,7 +191,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
onFileOpen
}) || {};
// Build the content component based on contentType
let contentComponent: React.ReactNode = null;
switch (displayConfig.contentType) {
@@ -211,7 +267,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
}
}
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath, {
old_string: contentProps.oldContent,
@@ -219,6 +274,8 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
})
: undefined;
const badgeElement = toolStatus && toolStatus !== 'completed' ? <ToolStatusBadge status={toolStatus} /> : undefined;
return (
<CollapsibleDisplay
toolName={toolName}
@@ -226,6 +283,7 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
title={title}
defaultOpen={defaultOpen}
onTitleClick={handleTitleClick}
badge={badgeElement}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolCategory={getToolCategory(toolName)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { CollapsibleSection } from './CollapsibleSection';
interface CollapsibleDisplayProps {
@@ -7,6 +8,7 @@ interface CollapsibleDisplayProps {
title: string;
defaultOpen?: boolean;
action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
showRawParameters?: boolean;
@@ -17,14 +19,14 @@ interface CollapsibleDisplayProps {
const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500',
search: 'border-l-muted-foreground/40',
bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400',
agent: 'border-l-purple-500 dark:border-l-purple-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
question: 'border-l-blue-500 dark:border-l-blue-400',
default: 'border-l-gray-300 dark:border-l-gray-600',
default: 'border-l-border',
};
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
@@ -32,14 +34,14 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
title,
defaultOpen = false,
action,
badge,
onTitleClick,
children,
showRawParameters = false,
rawContent,
className = '',
toolCategory
toolCategory,
}) => {
// Fall back to default styling for unknown/new categories so className never includes "undefined".
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return (
@@ -49,15 +51,16 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName={toolName}
open={defaultOpen}
action={action}
badge={badge}
onTitleClick={onTitleClick}
>
{children}
{showRawParameters && rawContent && (
<details className="group/raw relative mt-2">
<summary className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90"
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -65,11 +68,13 @@ export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</summary>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400">
{rawContent}
</pre>
</details>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
{rawContent}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</CollapsibleSection>
</div>

View File

@@ -1,10 +1,13 @@
import React from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
interface CollapsibleSectionProps {
title: string;
toolName?: string;
open?: boolean;
action?: React.ReactNode;
badge?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
className?: string;
@@ -18,44 +21,68 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
toolName,
open = false,
action,
badge,
onTitleClick,
children,
className = ''
className = '',
}) => {
return (
<details className={`group/details relative ${className}`} open={open}>
<summary className="flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1">
<svg
className="h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium text-gray-500 dark:text-gray-400">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600">/</span>
)}
{onTitleClick ? (
<Collapsible defaultOpen={open} className={cn('group/section', className)}>
{/* When there's a clickable title (Edit/Write), only the chevron toggles collapse */}
{onTitleClick ? (
<div className="flex cursor-default select-none items-center gap-1.5 py-0.5 text-xs group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
<CollapsibleTrigger className="flex flex-shrink-0 items-center p-0.5 text-muted-foreground hover:text-foreground">
<svg
className="h-3 w-3 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</CollapsibleTrigger>
{toolName && (
<span className="flex-shrink-0 font-medium text-muted-foreground">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
onClick={onTitleClick}
className="flex-1 truncate text-left font-mono text-primary transition-colors hover:text-primary/80 hover:underline"
>
{title}
</button>
) : (
<span className="flex-1 truncate text-gray-600 dark:text-gray-400">
{title}
</span>
)}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</summary>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</details>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</div>
) : (
<CollapsibleTrigger className="flex w-full select-none items-center gap-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground group-data-[state=open]/section:sticky group-data-[state=open]/section:top-0 group-data-[state=open]/section:z-10 group-data-[state=open]/section:-mx-1 group-data-[state=open]/section:bg-background group-data-[state=open]/section:px-1">
<svg
className="h-3 w-3 flex-shrink-0 transition-transform duration-150 group-data-[state=open]/section:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="flex-shrink-0 font-medium">{toolName}</span>
)}
{toolName && (
<span className="flex-shrink-0 text-[10px] text-muted-foreground/40">/</span>
)}
<span className="flex-1 truncate text-left">{title}</span>
{badge && <span className="ml-auto flex-shrink-0">{badge}</span>}
{action && <span className="ml-1 flex-shrink-0">{action}</span>}
</CollapsibleTrigger>
)}
<CollapsibleContent>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</CollapsibleContent>
</Collapsible>
);
};

View File

@@ -1,114 +1,21 @@
import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';
import { Badge } from '../../../../../shared/view/ui';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
import { Queue, QueueItem, QueueItemIndicator, QueueItemContent } from '../../../../../shared/view/ui';
import type { QueueItemStatus } from '../../../../../shared/view/ui';
export type TodoItem = {
id?: string;
content: string;
status: string;
priority?: string;
activeForm?: string;
};
type NormalizedTodoItem = {
id?: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
const normalizeStatus = (status: string): QueueItemStatus => {
if (status === 'completed') return 'completed';
if (status === 'in_progress') return 'in_progress';
return 'pending';
};
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800">
<div className="mt-0.5 flex-shrink-0">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex items-start justify-between gap-2">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex flex-shrink-0 gap-1">
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo(
({
todos,
@@ -117,36 +24,33 @@ const TodoList = memo(
todos: TodoItem[];
isResult?: boolean;
}) => {
// Memoize normalization to avoid recomputing list metadata on every render.
const normalizedTodos = useMemo<NormalizedTodoItem[]>(
() =>
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
const normalized = useMemo(
() => todos.map((todo) => ({ ...todo, queueStatus: normalizeStatus(todo.status) })),
[todos],
);
if (normalizedTodos.length === 0) {
return null;
}
if (normalized.length === 0) return null;
return (
<div className="space-y-1.5">
<div>
{isResult && (
<div className="mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Todo List ({normalizedTodos.length}{' '}
{normalizedTodos.length === 1 ? 'item' : 'items'})
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Todo List ({normalized.length} {normalized.length === 1 ? 'item' : 'items'})
</div>
)}
{normalizedTodos.map((todo, index) => (
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />
))}
<Queue>
{normalized.map((todo, index) => (
<QueueItem key={todo.id ?? `${todo.content}-${index}`} status={todo.queueStatus}>
<QueueItemIndicator />
<QueueItemContent>{todo.content}</QueueItemContent>
</QueueItem>
))}
</Queue>
</div>
);
}
},
);
TodoList.displayName = 'TodoList';
export default TodoList;

View File

@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { ToolStatusBadge } from './ToolStatusBadge';
import type { ToolStatus } from './ToolStatusBadge';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
@@ -23,6 +25,7 @@ interface OneLineDisplayProps {
resultId?: string;
toolResult?: any;
toolId?: string;
status?: ToolStatus;
}
/**
@@ -40,14 +43,15 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
style,
wrapText = false,
colorScheme = {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
primary: 'text-foreground',
secondary: 'text-muted-foreground',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
border: 'border-border',
icon: 'text-muted-foreground',
},
toolResult,
toolId
toolId,
status,
}) => {
const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal';
@@ -55,9 +59,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const handleAction = async () => {
if (action === 'copy' && value) {
const didCopy = await copyTextToClipboard(value);
if (!didCopy) {
return;
}
if (!didCopy) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else if (onAction) {
@@ -68,7 +70,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
const renderCopyButton = () => (
<button
onClick={handleAction}
className="ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200"
className="ml-1 flex-shrink-0 text-muted-foreground/40 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
@@ -84,7 +86,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
</button>
);
// Terminal style: dark pill only around the command
// Terminal style: dark pill around the command
if (isTerminal) {
return (
<div className="group my-1">
@@ -100,12 +102,13 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className="select-none text-green-600 dark:text-green-500">$ </span>{value}
</code>
</div>
{status && <ToolStatusBadge status={status} className="mt-0.5" />}
{action === 'copy' && renderCopyButton()}
</div>
</div>
{secondary && (
<div className="ml-7 mt-1">
<span className="text-[11px] italic text-gray-400 dark:text-gray-500">
<span className="text-[11px] italic text-muted-foreground/60">
{secondary}
</span>
</div>
@@ -114,20 +117,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
);
}
// File open style - show filename only, full path on hover
// File open style
if (action === 'open-file') {
const displayName = value.split('/').pop() || value;
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
<button
onClick={handleAction}
className="truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300"
className="truncate font-mono text-xs text-primary transition-colors hover:text-primary/80 hover:underline"
title={value}
>
{displayName}
</button>
{status && <ToolStatusBadge status={status} className="ml-auto" />}
</div>
);
}
@@ -136,20 +140,21 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
if (action === 'jump-to-results') {
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
<span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className="flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500">
<span className="flex-shrink-0 text-[11px] italic text-muted-foreground/60">
{secondary}
</span>
)}
{status && <ToolStatusBadge status={status} />}
{toolResult && (
<a
href={`#tool-result-${toolId}`}
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
className="flex flex-shrink-0 items-center gap-0.5 text-[11px] text-primary transition-colors hover:text-primary/80"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -167,10 +172,10 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)}
{!icon && (label || toolName) && (
<span className="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">{label || toolName}</span>
<span className="flex-shrink-0 text-xs text-muted-foreground">{label || toolName}</span>
)}
{(icon || label || toolName) && (
<span className="text-[10px] text-gray-300 dark:text-gray-600">/</span>
<span className="text-[10px] text-muted-foreground/40">/</span>
)}
<span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>
{value}
@@ -180,6 +185,7 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
{secondary}
</span>
)}
{status && <ToolStatusBadge status={status} />}
{action === 'copy' && renderCopyButton()}
</div>
);

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { ChevronsUpDown, FileText } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
Button,
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
Shimmer,
} from '../../../../shared/view/ui';
import { usePermission } from '../../../../contexts/PermissionContext';
import { MarkdownContent } from './ContentRenderers';
interface PlanDisplayProps {
title: string;
content: string;
defaultOpen?: boolean;
isStreaming?: boolean;
showRawParameters?: boolean;
rawContent?: string;
toolName: string;
toolId?: string;
}
export const PlanDisplay: React.FC<PlanDisplayProps> = ({
title,
content,
defaultOpen = false,
isStreaming = false,
showRawParameters = false,
rawContent,
toolName: _toolName,
}) => {
const permissionCtx = usePermission();
const pendingRequest = permissionCtx?.pendingPermissionRequests.find(
(r) => r.toolName === 'ExitPlanMode' || r.toolName === 'exit_plan_mode'
);
const handleBuild = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, { allow: true });
}
};
const handleRevise = () => {
if (pendingRequest && permissionCtx) {
permissionCtx.handlePermissionDecision(pendingRequest.requestId, {
allow: false,
message: 'User asked to revise the plan',
});
}
};
return (
<Collapsible defaultOpen={defaultOpen}>
<Card className="my-1 flex flex-col shadow-none">
{/* Header — always visible */}
<CardHeader className="flex flex-row items-start justify-between space-y-0 px-4 pb-0 pt-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<CardTitle className="text-sm font-semibold">
{isStreaming ? <Shimmer>{title}</Shimmer> : title}
</CardTitle>
</div>
<CollapsibleTrigger className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
</CardHeader>
{/* Collapsible content */}
<CollapsibleContent>
<CardContent className="px-4 pb-4 pt-3">
{content ? (
<MarkdownContent
content={content}
className="prose prose-sm max-w-none dark:prose-invert"
/>
) : isStreaming ? (
<div className="py-2">
<Shimmer>Generating plan...</Shimmer>
</div>
) : null}
{showRawParameters && rawContent && (
<Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-border/40 bg-muted p-2 font-mono text-[11px] text-muted-foreground">
{rawContent}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
{/* Footer — always visible when permission is pending */}
{pendingRequest && (
<CardFooter className="justify-end gap-2 border-t border-border/40 px-4 pb-3 pt-3">
<Button
variant="ghost"
size="sm"
onClick={handleRevise}
className="text-muted-foreground"
>
Revise
</Button>
<Button size="sm" onClick={handleBuild}>
Build{' '}
<kbd className="ml-1 rounded bg-primary-foreground/20 px-1 py-0.5 font-mono text-[10px]">
</kbd>
</Button>
</CardFooter>
)}
</Card>
</Collapsible>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import type { SubagentChildTool } from '../../types/types';
import { CollapsibleSection } from './CollapsibleSection';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../../../shared/view/ui';
interface SubagentContainerProps {
toolInput: unknown;
@@ -65,21 +66,21 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400">
<div className="mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
<span className="text-muted-foreground/60">Currently:</span>
<span className="font-medium text-foreground">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="truncate font-mono text-gray-500 dark:text-gray-400">
<span className="text-muted-foreground/40">/</span>
<span className="truncate font-mono text-muted-foreground">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
@@ -99,10 +100,10 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="group/history mt-2">
<summary className="flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<svg
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90"
className="h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 data-[state=open]:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -110,29 +111,31 @@ export const SubagentContainer: React.FC<SubagentContainerProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="truncate font-mono text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="flex-shrink-0 text-red-500">(error)</span>
)}
</div>
))}
</div>
</details>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 space-y-0.5 border-l border-border pl-3">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="w-4 flex-shrink-0 text-right text-muted-foreground/60">{index + 1}.</span>
<span className="font-medium text-foreground">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="truncate font-mono text-muted-foreground/70">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="flex-shrink-0 text-red-500">(error)</span>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
<div className="mt-2 text-xs text-muted-foreground">
{(() => {
let content = toolResult.content;

View File

@@ -0,0 +1,42 @@
import { cn } from '../../../../lib/utils';
export type ToolStatus = 'running' | 'completed' | 'error' | 'denied';
const STATUS_CONFIG: Record<ToolStatus, { label: string; className: string }> = {
running: {
label: 'Running',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
},
completed: {
label: 'Completed',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
},
error: {
label: 'Error',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
},
denied: {
label: 'Denied',
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300',
},
};
interface ToolStatusBadgeProps {
status: ToolStatus;
className?: string;
}
export function ToolStatusBadge({ status, className }: ToolStatusBadgeProps) {
const config = STATUS_CONFIG[status];
return (
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-px text-[10px] font-medium',
config.className,
className,
)}
>
{config.label}
</span>
);
}

View File

@@ -5,3 +5,5 @@ export { CollapsibleDisplay } from './CollapsibleDisplay';
export { SubagentContainer } from './SubagentContainer';
export * from './ContentRenderers';
export * from './InteractiveRenderers';
export { ToolStatusBadge } from './ToolStatusBadge';
export type { ToolStatus } from './ToolStatusBadge';

View File

@@ -5,7 +5,7 @@
export interface ToolDisplayConfig {
input: {
type: 'one-line' | 'collapsible' | 'hidden';
type: 'one-line' | 'collapsible' | 'plan' | 'hidden';
// One-line config
icon?: string;
label?: string;
@@ -31,7 +31,7 @@ export interface ToolDisplayConfig {
result?: {
hidden?: boolean;
hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'special';
type?: 'one-line' | 'collapsible' | 'plan' | 'special';
title?: string | ((result: any) => string);
defaultOpen?: boolean;
// Special result handlers
@@ -494,7 +494,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
exit_plan_mode: {
input: {
type: 'collapsible',
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
@@ -503,29 +503,14 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
hidden: true
}
},
// Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: {
input: {
type: 'collapsible',
type: 'plan',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
@@ -534,22 +519,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
console.warn('Failed to parse plan content:', e);
return { content: '' };
}
}
hidden: true
}
},

View File

@@ -1,6 +1,6 @@
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
export type Provider = SessionProvider;
export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';

View File

@@ -1,14 +1,17 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import type { SessionProvider } from '../../../types/app';
import type { LLMProvider } from '../../../types/app';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
@@ -206,9 +209,9 @@ function ChatInterface({
// so missed streaming events are shown. Also reset isLoading.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as SessionProvider,
provider: (selectedSession.__provider || providerVal) as LLMProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
@@ -267,6 +270,11 @@ function ChatInterface({
};
}, [resetStreamingState]);
const permissionContextValue = useMemo(() => ({
pendingPermissionRequests,
handlePermissionDecision,
}), [pendingPermissionRequests, handlePermissionDecision]);
if (!selectedProject) {
const selectedProviderLabel =
provider === 'cursor'
@@ -292,7 +300,7 @@ function ChatInterface({
}
return (
<>
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
@@ -393,7 +401,6 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
@@ -410,7 +417,7 @@ function ChatInterface({
</div>
<QuickSettingsPanel />
</>
</PermissionContext.Provider>
);
}

View File

@@ -11,12 +11,24 @@ import type {
SetStateAction,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
import {
PromptInput,
PromptInputHeader,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '../../../../shared/view/ui';
interface MentionableFile {
name: string;
@@ -86,7 +98,6 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
@@ -142,7 +153,6 @@ export default function ChatComposer({
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
isInputFocused,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
@@ -160,81 +170,43 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion'
);
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
return (
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
</div>
)}
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
{!hasQuestionPanel && <ChatInputControls
permissionMode={permissionMode}
onModeSwitch={onModeSwitch}
provider={provider}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput}
onClearInput={onClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>}
</div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
{isUserScrolledUp && hasMessages && (
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
<button
type="button"
onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
{attachedImages.length > 0 && (
<div className="mb-2 rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => (
@@ -272,21 +244,56 @@ export default function ChatComposer({
frequentCommands={frequentCommands}
/>
<div
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
{...getRootProps()}
className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
isTextareaExpanded ? 'chat-input-expanded' : ''
}`}
>
<input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40">
{renderInputWithMentions(input)}
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
</div>
)}
<div className="relative z-10">
<textarea
{attachedImages.length > 0 && (
<PromptInputHeader>
<div className="rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
</PromptInputHeader>
)}
<input {...getInputProps()} />
<PromptInputBody>
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words px-4 py-2 text-sm leading-6 text-transparent">
{renderInputWithMentions(input)}
</div>
</div>
<PromptInputTextarea
ref={textareaRef}
value={input}
onChange={onInputChange}
@@ -298,54 +305,110 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
tooltip={{ content: t('input.attachImages') }}
onClick={openImagePicker}
>
<ImageIcon />
</PromptInputButton>
<button
type="button"
onClick={openImagePicker}
className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60"
title={t('input.attachImages')}
onClick={onModeSwitch}
className={`rounded-lg border px-1.5 py-1 text-xs font-medium transition-all duration-200 sm:px-2.5 ${
permissionMode === 'default'
? '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'
}`}
title={t('input.clickToChangeMode')}
>
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
<div className="flex items-center gap-1.5">
<div
className={`h-2 w-2 rounded-full sm:h-1.5 sm:w-1.5 ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
</svg>
<span className="hidden sm:inline">
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
<button
type="submit"
disabled={!input.trim() || isLoading}
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event);
}}
className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}
onClick={onToggleCommandMenu}
className="relative"
>
<svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
<MessageSquareIcon />
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
>
{slashCommandsCount}
</span>
)}
</PromptInputButton>
{hasInput && (
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:inline-flex"
>
<XIcon />
</PromptInputButton>
)}
</PromptInputTools>
<div className="flex items-center gap-2">
<div
className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/>
</div>
</div>
</form>}
</PromptInputFooter>
</PromptInput>
</div>}
</div>
);
}

View File

@@ -1,137 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { PermissionMode, Provider } from '../../types/types';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
interface ChatInputControlsProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
provider: Provider | string;
thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
}
export default function ChatInputControls({
permissionMode,
onModeSwitch,
provider,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
}: ChatInputControlsProps) {
const { t } = useTranslation('chat');
return (
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
permissionMode === 'default'
? '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'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-1.5">
<div
className={`h-1.5 w-1.5 rounded-full ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span>
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button
type="button"
onClick={onToggleCommandMenu}
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
title={t('input.showAllCommands')}
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
>
{slashCommandsCount}
</span>
)}
</button>
{hasInput && (
<button
type="button"
onClick={onClearInput}
className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
title={t('input.clearInput', { defaultValue: 'Clear input' })}
>
<svg
className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isUserScrolledUp && hasMessages && (
<button
onClick={onScrollToBottom}
className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
@@ -15,8 +15,8 @@ interface ChatMessagesPaneProps {
chatMessages: ChatMessage[];
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
setProvider: (provider: SessionProvider) => void;
provider: LLMProvider;
setProvider: (provider: LLMProvider) => void;
textareaRef: RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;

View File

@@ -11,6 +11,7 @@ import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
@@ -68,7 +69,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
assistantCopyContent.trim().length > 0 &&
!isCommandOrFileEditToolResponse;
!isCommandOrFileEditToolResponse &&
!message.isThinking;
useEffect(() => {
@@ -378,36 +380,30 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</div>
</div>
) : message.isThinking ? (
/* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group">
<summary className="flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="h-3 w-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>{t('thinking.emoji')}</span>
</summary>
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
/* Thinking messages — Reasoning component (ai-elements pattern) */
<Reasoning defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<Markdown className="prose prose-sm prose-gray max-w-none dark:prose-invert">
{message.content}
</Markdown>
<div className="mt-3 flex items-center text-[11px]">
<MessageCopyControl content={String(message.content || '')} messageType="assistant" />
</div>
</details>
</div>
</ReasoningContent>
</Reasoning>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{/* Reasoning accordion */}
{showThinking && message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
{t('thinking.emoji')}
</summary>
<div className="mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400">
<Reasoning className="mb-3" defaultOpen={false}>
<ReasoningTrigger />
<ReasoningContent>
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</div>
</details>
</ReasoningContent>
</Reasoning>
)}
{(() => {

View File

@@ -1,9 +1,18 @@
import React from 'react';
import { ShieldAlertIcon } from 'lucide-react';
import type { PendingPermissionRequest } from '../../types/types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
import { getClaudeSettings } from '../../utils/chatStorage';
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
import {
Confirmation,
ConfirmationTitle,
ConfirmationRequest,
ConfirmationActions,
ConfirmationAction,
} from '../../../../shared/view/ui';
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
@@ -21,13 +30,18 @@ export default function PermissionRequestsBanner({
handlePermissionDecision,
handleGrantToolPermission,
}: PermissionRequestsBannerProps) {
if (!pendingPermissionRequests.length) {
// Filter out plan tool requests — they are handled inline by PlanDisplay
const filteredRequests = pendingPermissionRequests.filter(
(r) => r.toolName !== 'ExitPlanMode' && r.toolName !== 'exit_plan_mode'
);
if (!filteredRequests.length) {
return null;
}
return (
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
{filteredRequests.map((request) => {
const CustomPanel = getPermissionPanel(request.toolName);
if (CustomPanel) {
return (
@@ -54,69 +68,62 @@ export default function PermissionRequestsBanner({
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
<Confirmation key={request.requestId} approval="pending">
<ConfirmationTitle className="flex items-start gap-3">
<ShieldAlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<ConfirmationRequest>
<div>
<span className="font-medium text-foreground">Permission required</span>
<span className="ml-2 text-muted-foreground">
Tool: <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{request.toolName}</code>
</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{permissionEntry && (
<div className="mt-1 text-xs text-muted-foreground">
Allow rule: <code className="rounded bg-muted px-1 py-0.5 text-xs">{permissionEntry}</code>
</div>
)}
</ConfirmationRequest>
</ConfirmationTitle>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100">
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border bg-muted/50 p-2 text-xs text-muted-foreground">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700"
<ConfirmationActions>
<ConfirmationAction
variant="outline"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
>
Allow once
</button>
<button
type="button"
Deny
</ConfirmationAction>
<ConfirmationAction
variant="outline"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'cursor-not-allowed border-gray-300 text-gray-400'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30"
</ConfirmationAction>
<ConfirmationAction
variant="default"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
>
Deny
</button>
</div>
</div>
Allow once
</ConfirmationAction>
</ConfirmationActions>
</Confirmation>
);
})}
</div>

View File

@@ -1,6 +1,7 @@
import React from "react";
import React, { useCallback, useMemo, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
@@ -8,14 +9,27 @@ import {
CODEX_MODELS,
GEMINI_MODELS,
} from "../../../../../shared/modelConstants";
import type { ProjectSession, SessionProvider } from "../../../../types/app";
import type { ProjectSession, LLMProvider } from "../../../../types/app";
import { NextTaskBanner } from "../../../task-master";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
Card,
} from "../../../../shared/view/ui";
type ProviderSelectionEmptyStateProps = {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
setProvider: (next: SessionProvider) => void;
provider: LLMProvider;
setProvider: (next: LLMProvider) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
@@ -31,59 +45,28 @@ type ProviderSelectionEmptyStateProps = {
setInput: React.Dispatch<React.SetStateAction<string>>;
};
type ProviderDef = {
id: SessionProvider;
interface ProviderGroup {
id: LLMProvider;
name: string;
infoKey: string;
accent: string;
ring: string;
check: string;
};
models: { value: string; label: string }[];
}
const PROVIDERS: ProviderDef[] = [
{
id: "claude",
name: "Claude Code",
infoKey: "providerSelection.providerInfo.anthropic",
accent: "border-primary",
ring: "ring-primary/15",
check: "bg-primary text-primary-foreground",
},
{
id: "cursor",
name: "Cursor",
infoKey: "providerSelection.providerInfo.cursorEditor",
accent: "border-violet-500 dark:border-violet-400",
ring: "ring-violet-500/15",
check: "bg-violet-500 text-white",
},
{
id: "codex",
name: "Codex",
infoKey: "providerSelection.providerInfo.openai",
accent: "border-emerald-600 dark:border-emerald-400",
ring: "ring-emerald-600/15",
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
},
{
id: "gemini",
name: "Gemini",
infoKey: "providerSelection.providerInfo.google",
accent: "border-blue-500 dark:border-blue-400",
ring: "ring-blue-500/15",
check: "bg-blue-500 text-white",
},
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 },
];
function getModelConfig(p: SessionProvider) {
function getModelConfig(p: LLMProvider) {
if (p === "claude") return CLAUDE_MODELS;
if (p === "codex") return CODEX_MODELS;
if (p === "gemini") return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(
p: SessionProvider,
function getCurrentModel(
p: LLMProvider,
c: string,
cu: string,
co: string,
@@ -95,6 +78,13 @@ function getModelValue(
return cu;
}
function getProviderDisplayName(p: LLMProvider) {
if (p === "claude") return "Claude";
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
return "Gemini";
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
@@ -115,34 +105,12 @@ export default function ProviderSelectionEmptyState({
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const [dialogOpen, setDialogOpen] = useState(false);
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
const selectProvider = (next: SessionProvider) => {
setProvider(next);
localStorage.setItem("selected-provider", next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === "claude") {
setClaudeModel(value);
localStorage.setItem("claude-model", value);
} else if (provider === "codex") {
setCodexModel(value);
localStorage.setItem("codex-model", value);
} else if (provider === "gemini") {
setGeminiModel(value);
localStorage.setItem("gemini-model", value);
} else {
setCursorModel(value);
localStorage.setItem("cursor-model", value);
}
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(
const currentModel = getCurrentModel(
provider,
claudeModel,
cursorModel,
@@ -150,7 +118,42 @@ export default function ProviderSelectionEmptyState({
geminiModel,
);
/* ── New session — provider picker ── */
const currentModelLabel = useMemo(() => {
const config = getModelConfig(provider);
const found = config.OPTIONS.find(
(o: { value: string; label: string }) => o.value === currentModel,
);
return found?.label || currentModel;
}, [provider, currentModel]);
const handleModelSelect = useCallback(
(providerId: LLMProvider, modelValue: string) => {
// Set provider
setProvider(providerId);
localStorage.setItem("selected-provider", providerId);
// Set model for the correct provider
if (providerId === "claude") {
setClaudeModel(modelValue);
localStorage.setItem("claude-model", modelValue);
} else if (providerId === "codex") {
setCodexModel(modelValue);
localStorage.setItem("codex-model", modelValue);
} else if (providerId === "gemini") {
setGeminiModel(modelValue);
localStorage.setItem("gemini-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
setDialogOpen(false);
setTimeout(() => textareaRef.current?.focus(), 100);
},
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
);
/* ── New session — provider + model picker ── */
if (!selectedSession && !currentSessionId) {
return (
<div className="flex h-full items-center justify-center px-4">
@@ -165,96 +168,100 @@ export default function ProviderSelectionEmptyState({
</p>
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
{PROVIDERS.map((p) => {
const active = provider === p.id;
return (
<button
key={p.id}
onClick={() => selectProvider(p.id)}
className={`
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
pb-4 pt-5 transition-all duration-150
active:scale-[0.97]
${
active
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
}
`}
>
{/* Model selector trigger — hero card style */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Card
className="group mx-auto max-w-sm cursor-pointer border-border/60 transition-all duration-150 hover:border-border hover:shadow-md active:scale-[0.99]"
role="button"
tabIndex={0}
>
<div className="flex items-center gap-3 p-4">
<SessionProviderLogo
provider={p.id}
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
provider={provider}
className="h-8 w-8 shrink-0"
/>
<div className="text-center">
<p className="text-[13px] font-semibold leading-none text-foreground">
{p.name}
</p>
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
{t(p.infoKey)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-foreground">
{getProviderDisplayName(provider)}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="truncate text-sm text-foreground">
{currentModelLabel}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providerSelection.clickToChange", {
defaultValue: "Click to change model",
})}
</p>
</div>
{/* Check badge */}
{active && (
<div
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-y-0.5" />
</div>
</Card>
</DialogTrigger>
<DialogContent className="max-w-md overflow-hidden p-0">
<DialogTitle>Model Selector</DialogTitle>
<Command>
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
<CommandList className="max-h-[350px]">
<CommandEmpty>
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
</CommandEmpty>
{PROVIDER_GROUPS.map((group) => (
<CommandGroup
key={group.id}
heading={
<span className="flex items-center gap-1.5">
<SessionProviderLogo provider={group.id} className="h-3.5 w-3.5 shrink-0" />
{group.name}
</span>
}
>
<Check className="h-2.5 w-2.5" strokeWidth={3} />
</div>
)}
</button>
);
})}
</div>
{group.models.map((model) => {
const isSelected =
provider === group.id && currentModel === model.value;
return (
<CommandItem
key={`${group.id}-${model.value}`}
value={`${group.name} ${model.label}`}
onSelect={() => handleModelSelect(group.id, model.value)}
>
<span className="flex-1 truncate">{model.label}</span>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
{/* Model picker — appears after provider is chosen */}
<div
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
>
<div className="mb-5 flex items-center justify-center gap-2">
<span className="text-sm text-muted-foreground">
{t("providerSelection.selectModel")}
</span>
<div className="relative">
<select
value={currentModel}
onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1}
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(
({ value, label }: { value: string; label: string }) => (
<option key={value + label} value={value}>
{label}
</option>
),
)}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<p className="text-center text-sm text-muted-foreground/70">
{/* Ready prompt */}
<p className="mt-4 text-center text-sm text-muted-foreground/70">
{
{
{
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
</div>
claude: t("providerSelection.readyPrompt.claude", {
model: claudeModel,
}),
cursor: t("providerSelection.readyPrompt.cursor", {
model: cursorModel,
}),
codex: t("providerSelection.readyPrompt.codex", {
model: codexModel,
}),
gemini: t("providerSelection.readyPrompt.gemini", {
model: geminiModel,
}),
}[provider]
}
</p>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (

View File

@@ -1,11 +1,11 @@
import type { SessionProvider } from '../../types/app';
import type { LLMProvider } from '../../types/app';
import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
import GeminiLogo from './GeminiLogo';
type SessionProviderLogoProps = {
provider?: SessionProvider | string | null;
provider?: LLMProvider | string | null;
className?: string;
};

View File

@@ -1,17 +1,15 @@
import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { LLMProvider } from '../../../types/app';
import { authenticatedFetch } from '../../../utils/api';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import AgentConnectionsStep from './subcomponents/AgentConnectionsStep';
import GitConfigurationStep from './subcomponents/GitConfigurationStep';
import OnboardingStepProgress from './subcomponents/OnboardingStepProgress';
import type { CliProvider, ProviderStatusMap } from './types';
import {
cliProviders,
createInitialProviderStatuses,
gitEmailPattern,
readErrorMessageFromResponse,
selectedProject,
} from './utils';
type OnboardingProps = {
@@ -24,59 +22,14 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
const [gitEmail, setGitEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState<CliProvider | null>(null);
const [providerStatuses, setProviderStatuses] = useState<ProviderStatusMap>(createInitialProviderStatuses);
const [activeLoginProvider, setActiveLoginProvider] = useState<LLMProvider | null>(null);
const {
providerAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const previousActiveLoginProviderRef = useRef<CliProvider | null | undefined>(undefined);
const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => {
try {
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (!response.ok) {
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status',
},
}));
return;
}
const payload = (await response.json()) as {
authenticated?: boolean;
email?: string | null;
error?: string | null;
};
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
loading: false,
error: payload.error ?? null,
},
}));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
setProviderStatuses((previous) => ({
...previous,
[provider]: {
authenticated: false,
email: null,
loading: false,
error: caughtError instanceof Error ? caughtError.message : 'Unknown error',
},
}));
}
}, []);
const refreshAllProviderStatuses = useCallback(async () => {
await Promise.all(cliProviders.map((provider) => checkProviderAuthStatus(provider)));
}, [checkProviderAuthStatus]);
const previousActiveLoginProviderRef = useRef<LLMProvider | null | undefined>(undefined);
const loadGitConfig = useCallback(async () => {
try {
@@ -99,23 +52,24 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
useEffect(() => {
void loadGitConfig();
void refreshAllProviderStatuses();
}, [loadGitConfig, refreshAllProviderStatuses]);
void refreshProviderAuthStatuses();
}, [loadGitConfig, refreshProviderAuthStatuses]);
useEffect(() => {
const previousProvider = previousActiveLoginProviderRef.current;
previousActiveLoginProviderRef.current = activeLoginProvider;
const isInitialMount = previousProvider === undefined;
const didCloseModal = previousProvider !== null && activeLoginProvider === null;
const didCloseModal = previousProvider !== undefined
&& previousProvider !== null
&& activeLoginProvider === null;
// Refresh statuses once on mount and again after the login modal is closed.
if (isInitialMount || didCloseModal) {
void refreshAllProviderStatuses();
// Refresh statuses after the login modal is closed.
if (didCloseModal) {
void refreshProviderAuthStatuses();
}
}, [activeLoginProvider, refreshAllProviderStatuses]);
}, [activeLoginProvider, refreshProviderAuthStatuses]);
const handleProviderLoginOpen = (provider: CliProvider) => {
const handleProviderLoginOpen = (provider: LLMProvider) => {
setActiveLoginProvider(provider);
};
@@ -209,7 +163,7 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
/>
) : (
<AgentConnectionsStep
providerStatuses={providerStatuses}
providerStatuses={providerAuthStatus}
onOpenProviderLogin={handleProviderLoginOpen}
/>
)}
@@ -279,7 +233,6 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
isOpen={Boolean(activeLoginProvider)}
onClose={() => setActiveLoginProvider(null)}
provider={activeLoginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
/>
)}

View File

@@ -1,9 +1,10 @@
import { Check } from 'lucide-react';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { CliProvider, ProviderAuthStatus } from '../types';
import type { LLMProvider } from '../../../../types/app';
import type { ProviderAuthStatus } from '../../../provider-auth/types';
type AgentConnectionCardProps = {
provider: CliProvider;
provider: LLMProvider;
title: string;
status: ProviderAuthStatus;
connectedClassName: string;

View File

@@ -1,9 +1,10 @@
import type { CliProvider, ProviderStatusMap } from '../types';
import type { LLMProvider } from '../../../../types/app';
import type { ProviderAuthStatusMap } from '../../../provider-auth/types';
import AgentConnectionCard from './AgentConnectionCard';
type AgentConnectionsStepProps = {
providerStatuses: ProviderStatusMap;
onOpenProviderLogin: (provider: CliProvider) => void;
providerStatuses: ProviderAuthStatusMap;
onOpenProviderLogin: (provider: LLMProvider) => void;
};
const providerCards = [

View File

@@ -1,12 +0,0 @@
import type { CliProvider } from '../../provider-auth/types';
export type { CliProvider };
export type ProviderAuthStatus = {
authenticated: boolean;
email: string | null;
loading: boolean;
error: string | null;
};
export type ProviderStatusMap = Record<CliProvider, ProviderAuthStatus>;

View File

@@ -1,24 +1,5 @@
import { IS_PLATFORM } from '../../../constants/config';
import type { CliProvider, ProviderStatusMap } from './types';
export const cliProviders: CliProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const gitEmailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const selectedProject = {
name: 'default',
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',
};
export const createInitialProviderStatuses = (): ProviderStatusMap => ({
claude: { authenticated: false, email: null, loading: true, error: null },
cursor: { authenticated: false, email: null, loading: true, error: null },
codex: { authenticated: false, email: null, loading: true, error: null },
gemini: { authenticated: false, email: null, loading: true, error: null },
});
export const readErrorMessageFromResponse = async (response: Response, fallback: string) => {
try {
const payload = (await response.json()) as { error?: string };

View File

@@ -0,0 +1,109 @@
import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { LLMProvider } from '../../../types/app';
import {
CLI_AUTH_STATUS_ENDPOINTS,
CLI_PROVIDERS,
createInitialProviderAuthStatusMap,
} from '../types';
import type {
ProviderAuthStatus,
ProviderAuthStatusMap,
} from '../types';
type ProviderAuthStatusPayload = {
authenticated?: boolean;
email?: string | null;
method?: string | null;
error?: string | null;
};
const FALLBACK_STATUS_ERROR = 'Failed to check authentication status';
const FALLBACK_UNKNOWN_ERROR = 'Unknown error';
const toErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : FALLBACK_UNKNOWN_ERROR
);
const toProviderAuthStatus = (
payload: ProviderAuthStatusPayload,
fallbackError: string | null = null,
): ProviderAuthStatus => ({
authenticated: Boolean(payload.authenticated),
email: payload.email ?? null,
method: payload.method ?? null,
error: payload.error ?? fallbackError,
loading: false,
});
type UseProviderAuthStatusOptions = {
initialLoading?: boolean;
};
export function useProviderAuthStatus(
{ initialLoading = true }: UseProviderAuthStatusOptions = {},
) {
const [providerAuthStatus, setProviderAuthStatus] = useState<ProviderAuthStatusMap>(() => (
createInitialProviderAuthStatusMap(initialLoading)
));
const setProviderLoading = useCallback((provider: LLMProvider) => {
setProviderAuthStatus((previous) => ({
...previous,
[provider]: {
...previous[provider],
loading: true,
error: null,
},
}));
}, []);
const setProviderStatus = useCallback((provider: LLMProvider, status: ProviderAuthStatus) => {
setProviderAuthStatus((previous) => ({
...previous,
[provider]: status,
}));
}, []);
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
setProviderLoading(provider);
try {
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: FALLBACK_STATUS_ERROR,
});
return;
}
const payload = (await response.json()) as ProviderAuthStatusPayload;
setProviderStatus(provider, toProviderAuthStatus(payload));
} catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError);
setProviderStatus(provider, {
authenticated: false,
email: null,
method: null,
loading: false,
error: toErrorMessage(caughtError),
});
}
}, [setProviderLoading, setProviderStatus]);
const refreshProviderAuthStatuses = useCallback(async (providers: LLMProvider[] = CLI_PROVIDERS) => {
await Promise.all(providers.map((provider) => checkProviderAuthStatus(provider)));
}, [checkProviderAuthStatus]);
return {
providerAuthStatus,
setProviderAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
};
}

View File

@@ -1 +1,27 @@
export type CliProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
import type { LLMProvider } from '../../types/app';
export type ProviderAuthStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error: string | null;
loading: boolean;
};
export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
claude: { authenticated: false, email: null, method: null, error: null, loading },
cursor: { authenticated: false, email: null, method: null, error: null, loading },
codex: { authenticated: false, email: null, method: null, error: null, loading },
gemini: { authenticated: false, email: null, method: null, error: null, loading },
});

View File

@@ -1,21 +1,12 @@
import { ExternalLink, KeyRound, X } from 'lucide-react';
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../../../constants/config';
import type { CliProvider } from '../types';
type LoginModalProject = {
name?: string;
displayName?: string;
fullPath?: string;
path?: string;
[key: string]: unknown;
};
import { DEFAULT_PROJECT_FOR_EMPTY_SHELL, IS_PLATFORM } from '../../../constants/config';
import type { LLMProvider } from '../../../types/app';
type ProviderLoginModalProps = {
isOpen: boolean;
onClose: () => void;
provider?: CliProvider;
project?: LoginModalProject | null;
provider?: LLMProvider;
onComplete?: (exitCode: number) => void;
customCommand?: string;
isAuthenticated?: boolean;
@@ -26,7 +17,7 @@ const getProviderCommand = ({
customCommand,
isAuthenticated: _isAuthenticated,
}: {
provider: CliProvider;
provider: LLMProvider;
customCommand?: string;
isAuthenticated: boolean;
}) => {
@@ -49,30 +40,17 @@ const getProviderCommand = ({
return 'gemini status';
};
const getProviderTitle = (provider: CliProvider) => {
const getProviderTitle = (provider: LLMProvider) => {
if (provider === 'claude') return 'Claude CLI Login';
if (provider === 'cursor') return 'Cursor CLI Login';
if (provider === 'codex') return 'Codex CLI Login';
return 'Gemini CLI Configuration';
};
const normalizeProject = (project?: LoginModalProject | null) => {
const normalizedName = project?.name || 'default';
const normalizedFullPath = project?.fullPath ?? project?.path ?? (IS_PLATFORM ? '/workspace' : '');
return {
name: normalizedName,
displayName: project?.displayName || normalizedName,
fullPath: normalizedFullPath,
path: project?.path ?? normalizedFullPath,
};
};
export default function ProviderLoginModal({
isOpen,
onClose,
provider = 'claude',
project = null,
onComplete,
customCommand,
isAuthenticated = false,
@@ -83,7 +61,6 @@ export default function ProviderLoginModal({
const command = getProviderCommand({ provider, customCommand, isAuthenticated });
const title = getProviderTitle(provider);
const shellProject = normalizeProject(project);
const handleComplete = (exitCode: number) => {
onComplete?.(exitCode);
@@ -158,7 +135,7 @@ export default function ProviderLoginModal({
</button>
</div>
) : (
<StandaloneShell project={shellProject} command={command} onComplete={handleComplete} minimal={true} />
<StandaloneShell project={DEFAULT_PROJECT_FOR_EMPTY_SHELL} command={command} onComplete={handleComplete} minimal={true} />
)}
</div>
</div>

View File

@@ -1,7 +1,6 @@
import type {
AgentCategory,
AgentProvider,
AuthStatus,
ClaudeMcpFormState,
CodexMcpFormState,
CodeEditorSettingsState,
@@ -34,13 +33,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
fontSize: '14',
};
export const DEFAULT_AUTH_STATUS: AuthStatus = {
authenticated: false,
email: null,
loading: true,
error: null,
};
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
success: false,
message: '',
@@ -88,9 +80,3 @@ export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
skipPermissions: false,
};
export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
};

View File

@@ -1,15 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import {
AUTH_STATUS_ENDPOINTS,
DEFAULT_AUTH_STATUS,
DEFAULT_CODE_EDITOR_SETTINGS,
DEFAULT_CURSOR_PERMISSIONS,
} from '../constants/constants';
import type {
AgentProvider,
AuthStatus,
ClaudeMcpFormState,
ClaudePermissionsState,
CodeEditorSettingsState,
@@ -23,7 +21,6 @@ import type {
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
SettingsProject,
} from '../types/types';
type ThemeContextValue = {
@@ -34,15 +31,6 @@ type ThemeContextValue = {
type UseSettingsControllerArgs = {
isOpen: boolean;
initialTab: string;
projects: SettingsProject[];
onClose: () => void;
};
type StatusApiResponse = {
authenticated?: boolean;
email?: string | null;
error?: string | null;
method?: string;
};
type JsonResult = {
@@ -166,20 +154,6 @@ const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] =>
}))
);
const getDefaultProject = (projects: SettingsProject[]): SettingsProject => {
if (projects.length > 0) {
return projects[0];
}
const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '';
return {
name: 'default',
displayName: 'default',
fullPath: cwd,
path: cwd,
};
};
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
@@ -204,7 +178,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
},
});
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
@@ -242,64 +216,11 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const [selectedProject, setSelectedProject] = useState<SettingsProject | null>(null);
const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const [geminiAuthStatus, setGeminiAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
if (provider === 'claude') {
setClaudeAuthStatus(status);
return;
}
if (provider === 'cursor') {
setCursorAuthStatus(status);
return;
}
if (provider === 'gemini') {
setGeminiAuthStatus(status);
return;
}
setCodexAuthStatus(status);
}, []);
const checkAuthStatus = useCallback(async (provider: AgentProvider) => {
try {
const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setAuthStatusByProvider(provider, {
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status',
});
return;
}
const data = await toResponseJson<StatusApiResponse>(response);
setAuthStatusByProvider(provider, {
authenticated: Boolean(data.authenticated),
email: data.email || null,
loading: false,
error: data.error || null,
method: data.method,
});
} catch (error) {
console.error(`Error checking ${provider} auth status:`, error);
setAuthStatusByProvider(provider, {
authenticated: false,
email: null,
loading: false,
error: getErrorMessage(error),
});
}
}, [setAuthStatusByProvider]);
const {
providerAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const fetchCursorMcpServers = useCallback(async () => {
try {
@@ -724,9 +645,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
setSelectedProject(getDefaultProject(projects));
setShowLoginModal(true);
}, [projects]);
}, []);
const handleLoginComplete = useCallback((exitCode: number) => {
if (exitCode !== 0 || !loginProvider) {
@@ -734,8 +654,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
}
setSaveStatus('success');
void checkAuthStatus(loginProvider);
}, [checkAuthStatus, loginProvider]);
void checkProviderAuthStatus(loginProvider);
}, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => {
setSaveStatus(null);
@@ -827,11 +747,8 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
setActiveTab(normalizeMainTab(initialTab));
void loadSettings();
void checkAuthStatus('claude');
void checkAuthStatus('cursor');
void checkAuthStatus('codex');
void checkAuthStatus('gemini');
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
void refreshProviderAuthStatuses();
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
@@ -935,17 +852,13 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
openLoginForProvider,
showLoginModal,
setShowLoginModal,
loginProvider,
selectedProject,
handleLoginComplete,
};
}

View File

@@ -1,7 +1,9 @@
import type { Dispatch, SetStateAction } from 'react';
import type { LLMProvider } from '../../../types/app';
import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
@@ -18,13 +20,7 @@ export type SettingsProject = {
path?: string;
};
export type AuthStatus = {
authenticated: boolean;
email: string | null;
loading: boolean;
error: string | null;
method?: string;
};
export type AuthStatus = ProviderAuthStatus;
export type KeyValueMap = Record<string, string>;

View File

@@ -56,23 +56,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
openLoginForProvider,
showLoginModal,
setShowLoginModal,
loginProvider,
selectedProject,
handleLoginComplete,
} = useSettingsController({
isOpen,
initialTab,
projects,
onClose,
initialTab
});
const {
@@ -105,13 +99,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
return null;
}
const isAuthenticated = loginProvider === 'claude'
? claudeAuthStatus.authenticated
: loginProvider === 'cursor'
? cursorAuthStatus.authenticated
: loginProvider === 'codex'
? codexAuthStatus.authenticated
: false;
const isAuthenticated = Boolean(loginProvider && providerAuthStatus[loginProvider].authenticated);
return (
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4">
@@ -121,7 +109,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
<h2 className="text-base font-semibold text-foreground">{t('title')}</h2>
<div className="flex items-center gap-2">
{saveStatus === 'success' && (
<span className="text-xs text-muted-foreground animate-in fade-in">{t('saveStatus.success')}</span>
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
)}
<Button
variant="ghost"
@@ -158,14 +146,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
{activeTab === 'agents' && (
<AgentsSettingsTab
claudeAuthStatus={claudeAuthStatus}
cursorAuthStatus={cursorAuthStatus}
codexAuthStatus={codexAuthStatus}
geminiAuthStatus={geminiAuthStatus}
onClaudeLogin={() => openLoginForProvider('claude')}
onCursorLogin={() => openLoginForProvider('cursor')}
onCodexLogin={() => openLoginForProvider('codex')}
onGeminiLogin={() => openLoginForProvider('gemini')}
providerAuthStatus={providerAuthStatus}
onProviderLogin={openLoginForProvider}
claudePermissions={claudePermissions}
onClaudePermissionsChange={setClaudePermissions}
cursorPermissions={cursorPermissions}
@@ -219,7 +201,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
provider={loginProvider || 'claude'}
project={selectedProject}
onComplete={handleLoginComplete}
isAuthenticated={isAuthenticated}
/>

View File

@@ -6,14 +6,8 @@ import AgentSelectorSection from './sections/AgentSelectorSection';
import type { AgentContext, AgentsSettingsTabProps } from './types';
export default function AgentsSettingsTab({
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
geminiAuthStatus,
onClaudeLogin,
onCursorLogin,
onCodexLogin,
onGeminiLogin,
providerAuthStatus,
onProviderLogin,
claudePermissions,
onClaudePermissionsChange,
cursorPermissions,
@@ -41,30 +35,27 @@ export default function AgentsSettingsTab({
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
authStatus: claudeAuthStatus,
onLogin: onClaudeLogin,
authStatus: providerAuthStatus.claude,
onLogin: () => onProviderLogin('claude'),
},
cursor: {
authStatus: cursorAuthStatus,
onLogin: onCursorLogin,
authStatus: providerAuthStatus.cursor,
onLogin: () => onProviderLogin('cursor'),
},
codex: {
authStatus: codexAuthStatus,
onLogin: onCodexLogin,
authStatus: providerAuthStatus.codex,
onLogin: () => onProviderLogin('codex'),
},
gemini: {
authStatus: geminiAuthStatus,
onLogin: onGeminiLogin,
authStatus: providerAuthStatus.gemini,
onLogin: () => onProviderLogin('gemini'),
},
}), [
claudeAuthStatus,
codexAuthStatus,
cursorAuthStatus,
geminiAuthStatus,
onClaudeLogin,
onCodexLogin,
onCursorLogin,
onGeminiLogin,
onProviderLogin,
providerAuthStatus.claude,
providerAuthStatus.codex,
providerAuthStatus.cursor,
providerAuthStatus.gemini,
]);
return (

View File

@@ -17,16 +17,11 @@ export type AgentContext = {
};
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
export type ProviderAuthStatusByProvider = Record<AgentProvider, AuthStatus>;
export type AgentsSettingsTabProps = {
claudeAuthStatus: AuthStatus;
cursorAuthStatus: AuthStatus;
codexAuthStatus: AuthStatus;
geminiAuthStatus: AuthStatus;
onClaudeLogin: () => void;
onCursorLogin: () => void;
onCodexLogin: () => void;
onGeminiLogin: () => void;
providerAuthStatus: ProviderAuthStatusByProvider;
onProviderLogin: (provider: AgentProvider) => void;
claudePermissions: ClaudePermissionsState;
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
cursorPermissions: CursorPermissionsState;

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type React from 'react';
import type { TFunction } from 'i18next';
import { api } from '../../../utils/api';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
AdditionalSessionsByProject,
DeleteProjectConfirmation,
@@ -452,7 +452,7 @@ export function useSidebarController({
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async () => {
const confirmDeleteProject = useCallback(async (deleteData = false) => {
if (!deleteConfirmation) {
return;
}
@@ -464,7 +464,7 @@ export function useSidebarController({
setDeletingProjects((prev) => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(project.name, !isEmpty);
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
if (response.ok) {
onProjectDelete?.(project.name);
@@ -545,7 +545,7 @@ export function useSidebarController({
}, [onRefresh]);
const updateSessionSummary = useCallback(
async (_projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {
async (_projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
const trimmed = summary.trim();
if (!trimmed) {
setEditingSession(null);

View File

@@ -1,9 +1,9 @@
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../types/app';
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
export type ProjectSortOrder = 'name' | 'date';
export type SessionWithProvider = ProjectSession & {
__provider: SessionProvider;
__provider: LLMProvider;
};
export type AdditionalSessionsByProject = Record<string, ProjectSession[]>;
@@ -18,7 +18,7 @@ export type SessionDeleteConfirmation = {
projectName: string;
sessionId: string;
sessionTitle: string;
provider: SessionProvider;
provider: LLMProvider;
};
export type SidebarProps = {

View File

@@ -6,7 +6,7 @@ import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useSidebarController } from '../hooks/useSidebarController';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import type { Project, SessionProvider } from '../../../types/app';
import type { Project, LLMProvider } from '../../../types/app';
import type { MCPServerStatus, SidebarProps } from '../types/types';
import SidebarCollapsed from './subcomponents/SidebarCollapsed';
import SidebarContent from './subcomponents/SidebarContent';
@@ -177,7 +177,7 @@ function Sidebar({
setEditingSession(null);
setEditingSessionName('');
},
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
void updateSessionSummary(projectName, sessionId, summary, provider);
},
t,
@@ -235,7 +235,7 @@ function Sidebar({
isSearching={isSearching}
searchProgress={searchProgress}
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
const resolvedProvider = (provider || 'claude') as SessionProvider;
const resolvedProvider = (provider || 'claude') as LLMProvider;
const project = projects.find(p => p.name === projectName);
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
const sessionObj = {

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { AlertTriangle, Trash2 } from 'lucide-react';
import { AlertTriangle, EyeOff, Trash2 } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import Settings from '../../../settings/view/Settings';
@@ -22,7 +22,7 @@ type SidebarModalsProps = {
onProjectCreated: () => void;
deleteConfirmation: DeleteProjectConfirmation | null;
onCancelDeleteProject: () => void;
onConfirmDeleteProject: () => void;
onConfirmDeleteProject: (deleteData?: boolean) => void;
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
onCancelDeleteSession: () => void;
onConfirmDeleteSession: () => void;
@@ -104,8 +104,8 @@ export default function SidebarModals({
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
<AlertTriangle className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">
@@ -119,32 +119,32 @@ export default function SidebarModals({
?
</p>
{deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onConfirmDeleteProject(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.removeFromSidebar')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmDeleteProject}
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
onClick={() => onConfirmDeleteProject(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('actions.delete')}
{t('deleteConfirmation.deleteAllData')}
</Button>
<Button variant="ghost" className="w-full" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
</Button>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Tras
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
import { getTaskIndicatorStatus } from '../../utils/utils';
import TaskIndicator from './TaskIndicator';
@@ -38,14 +38,14 @@ type SidebarProjectItemProps = {
projectName: string,
sessionId: string,
sessionTitle: string,
provider: SessionProvider,
provider: LLMProvider,
) => void;
onLoadMoreSessions: (project: Project) => void;
onNewSession: (project: Project) => void;
onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void;
onCancelEditingSession: () => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
t: TFunction;
};

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import type { TFunction } from 'i18next';
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type {
LoadingSessionsByProject,
MCPServerStatus,
@@ -42,14 +42,14 @@ export type SidebarProjectListProps = {
projectName: string,
sessionId: string,
sessionTitle: string,
provider: SessionProvider,
provider: LLMProvider,
) => void;
onLoadMoreSessions: (project: Project) => void;
onNewSession: (project: Project) => void;
onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void;
onCancelEditingSession: () => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
t: TFunction;
};

View File

@@ -1,7 +1,7 @@
import { ChevronDown, Plus } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
import SidebarSessionItem from './SidebarSessionItem';
@@ -18,14 +18,14 @@ type SidebarProjectSessionsProps = {
onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void;
onCancelEditingSession: () => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
onDeleteSession: (
projectName: string,
sessionId: string,
sessionTitle: string,
provider: SessionProvider,
provider: LLMProvider,
) => void;
onLoadMoreSessions: (project: Project) => void;
onNewSession: (project: Project) => void;

View File

@@ -3,7 +3,7 @@ import type { TFunction } from 'i18next';
import { Badge, Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import { formatTimeAgo } from '../../../../utils/dateUtils';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
import { createSessionViewModel } from '../../utils/utils';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -18,14 +18,14 @@ type SidebarSessionItemProps = {
onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void;
onCancelEditingSession: () => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: LLMProvider) => void;
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
onDeleteSession: (
projectName: string,
sessionId: string,
sessionTitle: string,
provider: SessionProvider,
provider: LLMProvider,
) => void;
t: TFunction;
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { authenticatedFetch } from "../../../utils/api";
import { ReleaseInfo } from "../../../types/sharedTypes";
@@ -15,6 +15,8 @@ interface VersionUpgradeModalProps {
installMode: InstallMode;
}
const RELOAD_COUNTDOWN_START = 30;
export function VersionUpgradeModal({
isOpen,
onClose,
@@ -32,10 +34,30 @@ export function VersionUpgradeModal({
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
const [reloadCountdown, setReloadCountdown] = useState<number | null>(null);
useEffect(() => {
if (!IS_PLATFORM || reloadCountdown === null || reloadCountdown <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setReloadCountdown((previousCountdown) => {
if (previousCountdown === null) {
return null;
}
return Math.max(previousCountdown - 1, 0);
});
}, 1000);
return () => window.clearTimeout(timeoutId);
}, [reloadCountdown]);
const handleUpdateNow = useCallback(async () => {
setIsUpdating(true);
setUpdateOutput('Starting update...\n');
setReloadCountdown(IS_PLATFORM ? RELOAD_COUNTDOWN_START : null);
setUpdateError('');
try {
@@ -49,8 +71,7 @@ export function VersionUpgradeModal({
if (response.ok) {
setUpdateOutput(prev => prev + data.output + '\n');
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
const text = IS_PLATFORM ? 'Please refresh the page after 5 seconds to load the new version. If that doesn\'t work, RESTART the environment.' : 'Please restart the server to apply changes.';
setUpdateOutput(prev => prev + text + '\n');
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.' + '\n');
} else {
setUpdateError(data.error || 'Update failed');
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
@@ -147,6 +168,13 @@ export function VersionUpgradeModal({
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 dark:bg-gray-950">
<pre className="whitespace-pre-wrap font-mono text-xs text-green-400">{updateOutput}</pre>
</div>
{IS_PLATFORM && reloadCountdown !== null && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200">
{reloadCountdown === 0
? 'Refresh the page now. If that doesn\'t work, RESTART the environment.'
: `Refresh the page in ${reloadCountdown} ${reloadCountdown === 1 ? 'second' : 'seconds'}. If that doesn\'t work, RESTART the environment.`}
</div>
)}
{updateError && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
{updateError}

View File

@@ -2,4 +2,16 @@
* Environment Flag: Is Platform
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
export const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';
/**
* For empty shell instances where no project is provided,
* we use a default project object to ensure the shell can still function.
* This prevents errors related to missing project data.
*/
export const DEFAULT_PROJECT_FOR_EMPTY_SHELL = {
name: 'default',
displayName: 'default',
fullPath: IS_PLATFORM ? '/workspace' : '',
path: IS_PLATFORM ? '/workspace' : '',
};

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import type { PendingPermissionRequest } from '../components/chat/types/types';
export interface PermissionContextValue {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
}
const PermissionContext = createContext<PermissionContextValue | null>(null);
export function usePermission(): PermissionContextValue | null {
return useContext(PermissionContext);
}
export default PermissionContext;

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projekte",
"newProject": "Neues Projekt",
"deleteProject": "Projekt löschen",
"deleteProject": "Projekt entfernen",
"renameProject": "Projekt umbenennen",
"noProjects": "Keine Projekte gefunden",
"loadingProjects": "Projekte werden geladen...",
@@ -40,7 +40,7 @@
"createProject": "Neues Projekt erstellen",
"refresh": "Projekte und Sitzungen aktualisieren (Strg+R)",
"renameProject": "Projekt umbenennen (F2)",
"deleteProject": "Leeres Projekt löschen (Entf)",
"deleteProject": "Projekt aus Seitenleiste entfernen (Entf)",
"addToFavorites": "Zu Favoriten hinzufügen",
"removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten",
@@ -95,14 +95,14 @@
"deleteSuccess": "Erfolgreich gelöscht",
"errorOccurred": "Ein Fehler ist aufgetreten",
"deleteSessionConfirm": "Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteProjectConfirm": "Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteProjectConfirm": "Projekt aus der Seitenleiste entfernen? Deine Projektdateien, Erinnerungen und Sitzungsdaten werden nicht gelöscht.",
"enterProjectPath": "Bitte gib einen Projektpfad ein",
"deleteSessionFailed": "Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteSessionError": "Fehler beim Löschen der Sitzung. Bitte erneut versuchen.",
"renameSessionFailed": "Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.",
"renameSessionError": "Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Löschen des Projekts. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht entfernt werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Entfernen des Projekts. Bitte erneut versuchen.",
"createProjectFailed": "Projekt konnte nicht erstellt werden. Bitte erneut versuchen.",
"createProjectError": "Fehler beim Erstellen des Projekts. Bitte erneut versuchen."
},
@@ -122,12 +122,14 @@
"projectsScanned_other": "{{count}} Projekte durchsucht"
},
"deleteConfirmation": {
"deleteProject": "Projekt löschen",
"deleteProject": "Projekt entfernen",
"deleteSession": "Sitzung löschen",
"confirmDelete": "Möchtest du wirklich löschen",
"confirmDelete": "Was möchtest du mit",
"sessionCount_one": "Dieses Projekt enthält {{count}} Unterhaltung.",
"sessionCount_other": "Dieses Projekt enthält {{count}} Unterhaltungen.",
"allConversationsDeleted": "Alle Unterhaltungen werden dauerhaft gelöscht.",
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
"removeFromSidebar": "Nur aus der Seitenleiste entfernen",
"deleteAllData": "Alle Daten dauerhaft löschen",
"allConversationsDeleted": "Das Projekt wird aus der Seitenleiste entfernt. Deine Dateien, Erinnerungen und Sitzungsdaten bleiben erhalten.",
"cannotUndo": "Du kannst das Projekt später erneut hinzufügen."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projects",
"newProject": "New Project",
"deleteProject": "Delete Project",
"deleteProject": "Remove Project",
"renameProject": "Rename Project",
"noProjects": "No projects found",
"loadingProjects": "Loading projects...",
@@ -40,7 +40,7 @@
"createProject": "Create new project",
"refresh": "Refresh projects and sessions (Ctrl+R)",
"renameProject": "Rename project (F2)",
"deleteProject": "Delete empty project (Delete)",
"deleteProject": "Remove project from sidebar (Delete)",
"addToFavorites": "Add to favorites",
"removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name",
@@ -95,14 +95,14 @@
"deleteSuccess": "Deleted successfully",
"errorOccurred": "An error occurred",
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
"deleteProjectConfirm": "Remove this project from the sidebar? Your project files, memories, and session data will not be deleted.",
"enterProjectPath": "Please enter a project path",
"deleteSessionFailed": "Failed to delete session. Please try again.",
"deleteSessionError": "Error deleting session. Please try again.",
"renameSessionFailed": "Failed to rename session. Please try again.",
"renameSessionError": "Error renaming session. Please try again.",
"deleteProjectFailed": "Failed to delete project. Please try again.",
"deleteProjectError": "Error deleting project. Please try again.",
"deleteProjectFailed": "Failed to remove project. Please try again.",
"deleteProjectError": "Error removing project. Please try again.",
"createProjectFailed": "Failed to create project. Please try again.",
"createProjectError": "Error creating project. Please try again."
},
@@ -122,12 +122,14 @@
"projectsScanned_other": "{{count}} projects scanned"
},
"deleteConfirmation": {
"deleteProject": "Delete Project",
"deleteProject": "Remove Project",
"deleteSession": "Delete Session",
"confirmDelete": "Are you sure you want to delete",
"confirmDelete": "What would you like to do with",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
"removeFromSidebar": "Remove from sidebar only",
"deleteAllData": "Delete all data permanently",
"allConversationsDeleted": "The project will be removed from the sidebar. Your files, memories, and session data will be preserved.",
"cannotUndo": "You can re-add the project later."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "プロジェクト",
"newProject": "新規プロジェクト",
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"renameProject": "プロジェクト名を変更",
"noProjects": "プロジェクトが見つかりません",
"loadingProjects": "プロジェクトを読み込んでいます...",
@@ -40,7 +40,7 @@
"createProject": "新しいプロジェクトを作成",
"refresh": "プロジェクトとセッションを更新 (Ctrl+R)",
"renameProject": "プロジェクト名を変更 (F2)",
"deleteProject": "空のプロジェクトを除 (Delete)",
"deleteProject": "サイドバーからプロジェクトを除 (Delete)",
"addToFavorites": "お気に入りに追加",
"removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集",
@@ -94,14 +94,14 @@
"deleteSuccess": "削除しました",
"errorOccurred": "エラーが発生しました",
"deleteSessionConfirm": "このセッションを削除してもよろしいですか?この操作は取り消せません。",
"deleteProjectConfirm": "この空のプロジェクトを削除してもよろしいですか?この操作は取り消せません。",
"deleteProjectConfirm": "サイドバーからこのプロジェクトを除去しますか?プロジェクトファイル、メモリ、セッションデータは削除されません。",
"enterProjectPath": "プロジェクトのパスを入力してください",
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",
"createProjectError": "プロジェクトの作成でエラーが発生しました。もう一度お試しください。"
},
@@ -109,11 +109,13 @@
"updateAvailable": "アップデートあり"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"deleteSession": "セッションを削除",
"confirmDelete": "本当に削除しますか",
"confirmDelete": "このプロジェクトをどうしますか",
"sessionCount": "このプロジェクトには{{count}}件の会話があります。",
"allConversationsDeleted": "すべての会話が完全に削除されます。",
"cannotUndo": "この操作は取り消せません。"
"removeFromSidebar": "サイドバーからのみ除去",
"deleteAllData": "すべてのデータを完全に削除",
"allConversationsDeleted": "プロジェクトはサイドバーから除去されます。ファイル、メモリ、セッションデータは保持されます。",
"cannotUndo": "後からプロジェクトを再追加できます。"
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "프로젝트",
"newProject": "새 프로젝트",
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"renameProject": "프로젝트 이름 변경",
"noProjects": "프로젝트가 없습니다",
"loadingProjects": "프로젝트 로딩 중...",
@@ -40,7 +40,7 @@
"createProject": "새 프로젝트 생성",
"refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)",
"renameProject": "프로젝트 이름 변경 (F2)",
"deleteProject": " 프로젝트 제 (Delete)",
"deleteProject": "사이드바에서 프로젝트 제 (Delete)",
"addToFavorites": "즐겨찾기에 추가",
"removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집",
@@ -94,14 +94,14 @@
"deleteSuccess": "삭제되었습니다",
"errorOccurred": "오류가 발생했습니다",
"deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteProjectConfirm": "이 프로젝트를 제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteProjectConfirm": "사이드바에서 이 프로젝트를 제하시겠습니까? 프로젝트 파일, 메모리 및 세션 데이터는 삭제되지 않습니다.",
"enterProjectPath": "프로젝트 경로를 입력해주세요",
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
"createProjectError": "프로젝트 생성 오류. 다시 시도해주세요."
},
@@ -109,12 +109,14 @@
"updateAvailable": "업데이트 가능"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"deleteSession": "세션 삭제",
"confirmDelete": "정말 삭제하시겠습니까",
"confirmDelete": "이 프로젝트를 어떻게 하시겠습니까:",
"sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.",
"cannotUndo": "이 작업은 취소할 수 없습니다."
"removeFromSidebar": "사이드바에서만 제거",
"deleteAllData": "모든 데이터 영구 삭제",
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Проекты",
"newProject": "Новый проект",
"deleteProject": "Удалить проект",
"deleteProject": "Убрать проект",
"renameProject": "Переименовать проект",
"noProjects": "Проекты не найдены",
"loadingProjects": "Загрузка проектов...",
@@ -40,7 +40,7 @@
"createProject": "Создать новый проект",
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
"renameProject": "Переименовать проект (F2)",
"deleteProject": "Удалить пустой проект (Delete)",
"deleteProject": "Убрать проект из боковой панели (Delete)",
"addToFavorites": "Добавить в избранное",
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
@@ -95,14 +95,14 @@
"deleteSuccess": "Успешно удалено",
"errorOccurred": "Произошла ошибка",
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
"deleteProjectConfirm": "Убрать этот проект из боковой панели? Файлы проекта, воспоминания и данные сеансов не будут удалены.",
"enterProjectPath": "Пожалуйста, введите путь к проекту",
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
"deleteProjectFailed": "Не удалось убрать проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта из списка. Попробуйте снова.",
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
},
@@ -126,14 +126,16 @@
"projectsScanned_other": "{{count}} проектов просканировано"
},
"deleteConfirmation": {
"deleteProject": "Удалить проект",
"deleteProject": "Убрать проект",
"deleteSession": "Удалить сеанс",
"confirmDelete": "Вы уверены, что хотите удалить",
"confirmDelete": "Что вы хотите сделать с",
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
"cannotUndo": "Это действие нельзя отменить."
"removeFromSidebar": "Убрать только из боковой панели",
"deleteAllData": "Удалить все данные навсегда",
"allConversationsDeleted": "Проект будет убран из боковой панели. Ваши файлы, воспоминания и данные сеансов сохранятся.",
"cannotUndo": "Вы сможете добавить проект позже."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "项目",
"newProject": "新建项目",
"deleteProject": "除项目",
"deleteProject": "除项目",
"renameProject": "重命名项目",
"noProjects": "未找到项目",
"loadingProjects": "加载项目中...",
@@ -40,7 +40,7 @@
"createProject": "创建新项目",
"refresh": "刷新项目和会话 (Ctrl+R)",
"renameProject": "重命名项目 (F2)",
"deleteProject": "删除空项目 (Delete)",
"deleteProject": "从侧边栏移除项目 (Delete)",
"addToFavorites": "添加到收藏",
"removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称",
@@ -95,14 +95,14 @@
"deleteSuccess": "删除成功",
"errorOccurred": "发生错误",
"deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。",
"deleteProjectConfirm": "确定要删除此项目吗?此操作无法撤销。",
"deleteProjectConfirm": "从侧边栏移除此项目?您的项目文件、记忆和会话数据不会被删除。",
"enterProjectPath": "请输入项目路径",
"deleteSessionFailed": "删除会话失败,请重试。",
"deleteSessionError": "删除会话时出错,请重试。",
"renameSessionFailed": "重命名会话失败,请重试。",
"renameSessionError": "重命名会话时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"createProjectFailed": "创建项目失败,请重试。",
"createProjectError": "创建项目时出错,请重试。"
},
@@ -122,12 +122,14 @@
"projectsScanned_other": "{{count}} 个项目已扫描"
},
"deleteConfirmation": {
"deleteProject": "除项目",
"deleteProject": "除项目",
"deleteSession": "删除会话",
"confirmDelete": "您确定要删除",
"confirmDelete": "您想如何处理",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
"removeFromSidebar": "仅从侧边栏移除",
"deleteAllData": "永久删除所有数据",
"allConversationsDeleted": "项目将从侧边栏中移除。您的文件、记忆和会话数据将会保留。",
"cannotUndo": "您可以稍后重新添加此项目。"
}
}

View File

@@ -0,0 +1,64 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
);
type AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>;
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
data-slot="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-title"
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
)
);
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription, alertVariants };

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
const badgeVariants = cva(

View File

@@ -1,10 +1,11 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../../lib/utils';
// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex touch-manipulation items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-4', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-4 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
/**
* Use inside a CardHeader with `className="flex flex-row items-start justify-between"`.
* Positions an action (button/icon) at the trailing edge of the header.
*/
const CardAction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('ml-auto shrink-0', className)}
{...props}
/>
)
);
CardAction.displayName = 'CardAction';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction };

View File

@@ -0,0 +1,103 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
interface CollapsibleContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null);
function useCollapsible() {
const ctx = React.useContext(CollapsibleContext);
if (!ctx) throw new Error('Collapsible components must be used within <Collapsible>');
return ctx;
}
interface CollapsibleProps extends React.HTMLAttributes<HTMLDivElement> {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
({ defaultOpen = false, open: controlledOpen, onOpenChange: controlledOnOpenChange, className, children, ...props }, ref) => {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const onOpenChange = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
controlledOnOpenChange?.(next);
},
[isControlled, controlledOnOpenChange]
);
const value = React.useMemo(() => ({ open, onOpenChange }), [open, onOpenChange]);
return (
<CollapsibleContext.Provider value={value}>
<div ref={ref} data-state={open ? 'open' : 'closed'} className={className} {...props}>
{children}
</div>
</CollapsibleContext.Provider>
);
}
);
Collapsible.displayName = 'Collapsible';
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, children, className, ...props }, ref) => {
const { open, onOpenChange } = useCollapsible();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onOpenChange(!open);
onClick?.(e);
},
[open, onOpenChange, onClick]
);
return (
<button
ref={ref}
type="button"
aria-expanded={open}
data-state={open ? 'open' : 'closed'}
onClick={handleClick}
className={className}
{...props}
>
{children}
</button>
);
}
);
CollapsibleTrigger.displayName = 'CollapsibleTrigger';
const CollapsibleContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { open } = useCollapsible();
return (
<div
ref={ref}
data-state={open ? 'open' : 'closed'}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
className
)}
{...props}
>
<div className="overflow-hidden">
{children}
</div>
</div>
);
}
);
CollapsibleContent.displayName = 'CollapsibleContent';
export { Collapsible, CollapsibleTrigger, CollapsibleContent, useCollapsible };

View File

@@ -0,0 +1,320 @@
import * as React from 'react';
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.
*/
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
ref={ref}
role="listbox"
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
)
);
CommandList.displayName = 'CommandList';
/* ─── CommandEmpty ───────────────────────────────────────────────── */
const CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { search, visibleIds } = useCommand();
// Only show when there's a search term and zero matches
if (!search || visibleIds.size > 0) return null;
return (
<div ref={ref} className={cn('py-6 text-center text-sm text-muted-foreground', className)} {...props} />
);
}
);
CommandEmpty.displayName = 'CommandEmpty';
/* ─── 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';
export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator };

View File

@@ -0,0 +1,139 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
import { Alert } from './Alert';
import { Button } from './Button';
/* ─── Context ────────────────────────────────────────────────────── */
type ApprovalState = 'pending' | 'approved' | 'rejected' | undefined;
interface ConfirmationContextValue {
approval: ApprovalState;
}
const ConfirmationContext = React.createContext<ConfirmationContextValue | null>(null);
const useConfirmation = () => {
const context = React.useContext(ConfirmationContext);
if (!context) {
throw new Error('Confirmation components must be used within Confirmation');
}
return context;
};
/* ─── Confirmation (root) ────────────────────────────────────────── */
export interface ConfirmationProps extends React.HTMLAttributes<HTMLDivElement> {
approval?: ApprovalState;
}
export const Confirmation: React.FC<ConfirmationProps> = ({
className,
approval = 'pending',
children,
...props
}) => {
const contextValue = React.useMemo(() => ({ approval }), [approval]);
return (
<ConfirmationContext.Provider value={contextValue}>
<Alert className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</Alert>
</ConfirmationContext.Provider>
);
};
Confirmation.displayName = 'Confirmation';
/* ─── ConfirmationTitle ──────────────────────────────────────────── */
export type ConfirmationTitleProps = React.HTMLAttributes<HTMLDivElement>;
export const ConfirmationTitle: React.FC<ConfirmationTitleProps> = ({
className,
...props
}) => (
<div
data-slot="confirmation-title"
className={cn('text-muted-foreground inline text-sm', className)}
{...props}
/>
);
ConfirmationTitle.displayName = 'ConfirmationTitle';
/* ─── ConfirmationRequest — visible only when pending ────────────── */
export interface ConfirmationRequestProps {
children?: React.ReactNode;
}
export const ConfirmationRequest: React.FC<ConfirmationRequestProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'pending') return null;
return <>{children}</>;
};
ConfirmationRequest.displayName = 'ConfirmationRequest';
/* ─── ConfirmationAccepted — visible only when approved ──────────── */
export interface ConfirmationAcceptedProps {
children?: React.ReactNode;
}
export const ConfirmationAccepted: React.FC<ConfirmationAcceptedProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'approved') return null;
return <>{children}</>;
};
ConfirmationAccepted.displayName = 'ConfirmationAccepted';
/* ─── ConfirmationRejected — visible only when rejected ──────────── */
export interface ConfirmationRejectedProps {
children?: React.ReactNode;
}
export const ConfirmationRejected: React.FC<ConfirmationRejectedProps> = ({ children }) => {
const { approval } = useConfirmation();
if (approval !== 'rejected') return null;
return <>{children}</>;
};
ConfirmationRejected.displayName = 'ConfirmationRejected';
/* ─── ConfirmationActions — visible only when pending ────────────── */
export type ConfirmationActionsProps = React.HTMLAttributes<HTMLDivElement>;
export const ConfirmationActions: React.FC<ConfirmationActionsProps> = ({
className,
...props
}) => {
const { approval } = useConfirmation();
if (approval !== 'pending') return null;
return (
<div
data-slot="confirmation-actions"
className={cn('flex items-center justify-end gap-2 self-end', className)}
{...props}
/>
);
};
ConfirmationActions.displayName = 'ConfirmationActions';
/* ─── ConfirmationAction — styled button ─────────────────────────── */
export type ConfirmationActionProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'default' | 'outline' | 'ghost' | 'destructive';
};
export const ConfirmationAction: React.FC<ConfirmationActionProps> = ({
variant = 'default',
...props
}) => (
<Button className="h-8 px-3 text-sm" variant={variant} type="button" {...props} />
);
ConfirmationAction.displayName = 'ConfirmationAction';
export { useConfirmation };

View File

@@ -1,4 +1,5 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { cn } from '../../../lib/utils';

View File

@@ -0,0 +1,217 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../../lib/utils';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: React.MutableRefObject<HTMLElement | null>;
}
const DialogContext = React.createContext<DialogContextValue | null>(null);
function useDialog() {
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('Dialog components must be used within <Dialog>');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children }) => {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const triggerRef = React.useRef<HTMLElement | null>(null) as React.MutableRefObject<HTMLElement | null>;
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const onOpenChange = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
controlledOnOpenChange?.(next);
},
[isControlled, controlledOnOpenChange]
);
const value = React.useMemo(() => ({ open, onOpenChange, triggerRef }), [open, onOpenChange]);
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
};
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }>(
({ onClick, children, asChild, ...props }, ref) => {
const { onOpenChange, triggerRef } = useDialog();
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onOpenChange(true);
onClick?.(e);
},
[onOpenChange, onClick]
);
// asChild: clone child element and compose onClick + capture ref
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
onClick: (e: React.MouseEvent<HTMLElement>) => {
onOpenChange(true);
child.props.onClick?.(e);
},
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
// Forward the outer ref
if (typeof ref === 'function') ref(node as any);
else if (ref) (ref as React.MutableRefObject<any>).current = node;
},
});
}
return (
<button
ref={(node) => {
triggerRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
type="button"
onClick={handleClick}
{...props}
>
{children}
</button>
);
}
);
DialogTrigger.displayName = 'DialogTrigger';
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
onEscapeKeyDown?: () => void;
onPointerDownOutside?: () => void;
}
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
const { open, onOpenChange, triggerRef } = useDialog();
const contentRef = React.useRef<HTMLDivElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
// Save the element that had focus before opening, restore on close
React.useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
} else if (previousFocusRef.current) {
// Prefer the trigger, fall back to whatever was focused before
const restoreTarget = triggerRef.current || previousFocusRef.current;
restoreTarget?.focus();
previousFocusRef.current = null;
}
}, [open, triggerRef]);
React.useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscapeKeyDown?.();
onOpenChange(false);
return;
}
// Focus trap: Tab / Shift+Tab cycle within the dialog
if (e.key === 'Tab' && contentRef.current) {
const focusable = Array.from(
contentRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
// Prevent body scroll
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.body.style.overflow = prev;
};
}, [open, onOpenChange, onEscapeKeyDown]);
// Auto-focus first focusable element on open
React.useEffect(() => {
if (open && contentRef.current) {
// Small delay to let the portal render
requestAnimationFrame(() => {
const first = contentRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
first?.focus();
});
}
}, [open]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50">
{/* Overlay */}
<div
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"
onClick={() => {
onPointerDownOutside?.();
onOpenChange(false);
}}
aria-hidden
/>
{/* Content */}
<div
ref={(node) => {
contentRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
'rounded-xl border bg-popover text-popover-foreground shadow-lg',
'animate-dialog-content-show',
className
)}
{...props}
>
{children}
</div>
</div>,
document.body
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2 ref={ref} className={cn('sr-only', className)} {...props} />
)
);
DialogTitle.displayName = 'DialogTitle';
export { Dialog, DialogTrigger, DialogContent, DialogTitle, useDialog };

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { cn } from '../../../lib/utils';
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

View File

@@ -2,6 +2,7 @@
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { languages } from '../../../i18n/languages';
type LanguageSelectorProps = {

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { cn } from '../../../lib/utils';
/* ── Container ─────────────────────────────────────────────────── */

View File

@@ -0,0 +1,219 @@
"use client";
import * as React from 'react';
import { SendHorizonalIcon, SquareIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
import Tooltip from './Tooltip';
/* ─── Context ────────────────────────────────────────────────────── */
type PromptInputStatus = 'ready' | 'submitted' | 'streaming' | 'error';
interface PromptInputContextValue {
status: PromptInputStatus;
}
const PromptInputContext = React.createContext<PromptInputContextValue | null>(null);
const usePromptInput = () => {
const context = React.useContext(PromptInputContext);
if (!context) {
throw new Error('PromptInput components must be used within PromptInput');
}
return context;
};
/* ─── PromptInput (root form) ────────────────────────────────────── */
export interface PromptInputProps extends React.FormHTMLAttributes<HTMLFormElement> {
status?: PromptInputStatus;
}
export const PromptInput = React.forwardRef<HTMLFormElement, PromptInputProps>(
({ className, status = 'ready', children, ...props }, ref) => {
const contextValue = React.useMemo(() => ({ status }), [status]);
return (
<PromptInputContext.Provider value={contextValue}>
<form
ref={ref}
data-slot="prompt-input"
className={cn(
'relative overflow-hidden rounded-xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15',
className
)}
{...props}
>
{children}
</form>
</PromptInputContext.Provider>
);
}
);
PromptInput.displayName = 'PromptInput';
/* ─── PromptInputHeader ──────────────────────────────────────────── */
export const PromptInputHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-header"
className={cn('px-3 pt-3', className)}
{...props}
/>
));
PromptInputHeader.displayName = 'PromptInputHeader';
/* ─── PromptInputBody ────────────────────────────────────────────── */
export const PromptInputBody = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-body"
className={cn('relative', className)}
{...props}
/>
));
PromptInputBody.displayName = 'PromptInputBody';
/* ─── PromptInputTextarea ────────────────────────────────────────── */
export const PromptInputTextarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="prompt-input-textarea"
className={cn(
'chat-input-placeholder block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-4 py-2 text-sm leading-6 text-foreground placeholder-muted-foreground/50 focus:outline-none sm:max-h-[300px]',
className
)}
{...props}
/>
));
PromptInputTextarea.displayName = 'PromptInputTextarea';
/* ─── PromptInputFooter ──────────────────────────────────────────── */
export const PromptInputFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-footer"
className={cn('flex items-center justify-between border-t border-border/30 px-3 py-2', className)}
{...props}
/>
));
PromptInputFooter.displayName = 'PromptInputFooter';
/* ─── PromptInputTools ───────────────────────────────────────────── */
export const PromptInputTools = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="prompt-input-tools"
className={cn('flex items-center gap-1', className)}
{...props}
/>
));
PromptInputTools.displayName = 'PromptInputTools';
/* ─── PromptInputButton ──────────────────────────────────────────── */
export interface PromptInputButtonTooltip {
content: React.ReactNode;
shortcut?: string;
side?: 'top' | 'bottom' | 'left' | 'right';
}
export interface PromptInputButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tooltip?: PromptInputButtonTooltip;
}
export const PromptInputButton = React.forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ className, tooltip, children, ...props }, ref) => {
const button = (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
className={cn('h-8 w-8 [&_svg]:size-4', className)}
{...props}
>
{children}
</Button>
);
if (tooltip) {
return (
<Tooltip
content={
tooltip.shortcut ? (
<span className="flex items-center gap-1.5">
{tooltip.content}
<kbd className="rounded bg-white/20 px-1 text-[10px]">{tooltip.shortcut}</kbd>
</span>
) : (
tooltip.content
)
}
position={tooltip.side ?? 'top'}
>
{button}
</Tooltip>
);
}
return button;
}
);
PromptInputButton.displayName = 'PromptInputButton';
/* ─── PromptInputSubmit ──────────────────────────────────────────── */
export interface PromptInputSubmitProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
status?: PromptInputStatus;
}
export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
({ className, status: statusProp, children, ...props }, ref) => {
const context = React.useContext(PromptInputContext);
const status = statusProp ?? context?.status ?? 'ready';
const isActive = status === 'submitted' || status === 'streaming';
return (
<Button
ref={ref}
type={isActive ? 'button' : 'submit'}
variant="default"
size="icon"
className={cn('h-8 w-8 rounded-lg', className)}
{...props}
>
{children ?? (isActive ? (
<SquareIcon className="h-3.5 w-3.5 fill-current" />
) : (
<SendHorizonalIcon className="h-4 w-4" />
))}
</Button>
);
}
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
export { usePromptInput };

Some files were not shown because too many files have changed in this diff Show More