mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
Compare commits
6 Commits
fix/duplic
...
v1.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af72570b3 | ||
|
|
12e7f074d9 | ||
|
|
e52e1a2b58 | ||
|
|
d258f4f0c7 | ||
|
|
1dc2a205dc | ||
|
|
9bceab9e1a |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -3,6 +3,22 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)
|
||||
|
||||
### New Features
|
||||
|
||||
* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))
|
||||
* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))
|
||||
* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))
|
||||
|
||||
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<h3>CLI Selection</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Select between Claude Code, Cursor CLI and Codex</em>
|
||||
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.25.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 340 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 506 KiB |
@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
|
||||
// Create database connection
|
||||
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
|
||||
)`);
|
||||
|
||||
// Show app installation path prominently
|
||||
const appInstallPath = path.join(__dirname, '../..');
|
||||
console.log('');
|
||||
@@ -91,6 +100,13 @@ const runMigrations = () => {
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -414,6 +430,33 @@ function applyCustomSessionNames(sessions, provider) {
|
||||
}
|
||||
}
|
||||
|
||||
// App config database operations
|
||||
const appConfigDb = {
|
||||
get: (key) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
|
||||
return row?.value || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set: (key, value) => {
|
||||
db.prepare(
|
||||
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, value);
|
||||
},
|
||||
|
||||
getOrCreateJwtSecret: () => {
|
||||
let secret = appConfigDb.get('jwt_secret');
|
||||
if (!secret) {
|
||||
secret = crypto.randomBytes(64).toString('hex');
|
||||
appConfigDb.set('jwt_secret', secret);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
@@ -441,5 +484,6 @@ export {
|
||||
credentialsDb,
|
||||
sessionNamesDb,
|
||||
applyCustomSessionNames,
|
||||
appConfigDb,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
@@ -62,4 +62,11 @@ CREATE TABLE IF NOT EXISTS session_names (
|
||||
UNIQUE(session_id, provider)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(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/index.js
102
server/index.js
@@ -326,7 +326,7 @@ const wss = new WebSocketServer({
|
||||
// Make WebSocket server available to routes
|
||||
app.locals.wss = wss;
|
||||
|
||||
app.use(cors());
|
||||
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
|
||||
app.use(express.json({
|
||||
limit: '50mb',
|
||||
type: (req) => {
|
||||
@@ -1699,50 +1699,43 @@ function handleShellConnection(ws) {
|
||||
}));
|
||||
|
||||
try {
|
||||
// Prepare the shell command adapted to the platform and provider
|
||||
// Validate projectPath — resolve to absolute and verify it exists
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
try {
|
||||
const stats = fs.statSync(resolvedProjectPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error('Not a directory');
|
||||
}
|
||||
} catch (pathErr) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate sessionId — only allow safe characters
|
||||
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build shell command — use cwd for project path (never interpolate into shell string)
|
||||
let shellCommand;
|
||||
if (isPlainShell) {
|
||||
// Plain shell mode - just run the initial command in the project directory
|
||||
if (os.platform() === 'win32') {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
||||
}
|
||||
// Plain shell mode - run the initial command in the project directory
|
||||
shellCommand = initialCommand;
|
||||
} else if (provider === 'cursor') {
|
||||
// Use cursor-agent command
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
|
||||
}
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `cursor-agent --resume="${sessionId}"`;
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
||||
}
|
||||
shellCommand = 'cursor-agent';
|
||||
}
|
||||
|
||||
} else if (provider === 'codex') {
|
||||
// Use codex command
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
||||
}
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `codex resume "${sessionId}" || codex`;
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && codex`;
|
||||
}
|
||||
shellCommand = 'codex';
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Use gemini command
|
||||
const command = initialCommand || 'gemini';
|
||||
let resumeId = sessionId;
|
||||
if (hasSession && sessionId) {
|
||||
@@ -1753,41 +1746,28 @@ function handleShellConnection(ws) {
|
||||
const sess = sessionManager.getSession(sessionId);
|
||||
if (sess && sess.cliSessionId) {
|
||||
resumeId = sess.cliSessionId;
|
||||
// Validate the looked-up CLI session ID too
|
||||
if (!safeSessionIdPattern.test(resumeId)) {
|
||||
resumeId = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get Gemini CLI session ID:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && resumeId) {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
||||
}
|
||||
if (hasSession && resumeId) {
|
||||
shellCommand = `${command} --resume "${resumeId}"`;
|
||||
} else {
|
||||
if (hasSession && resumeId) {
|
||||
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
||||
}
|
||||
shellCommand = command;
|
||||
}
|
||||
} else {
|
||||
// Use claude command (default) or initialCommand if provided
|
||||
// Claude (default provider)
|
||||
const command = initialCommand || 'claude';
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to new session if it fails
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
||||
}
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `claude --resume "${sessionId}" || claude`;
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
||||
}
|
||||
shellCommand = command;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1806,7 +1786,7 @@ function handleShellConnection(ws) {
|
||||
name: 'xterm-256color',
|
||||
cols: termCols,
|
||||
rows: termRows,
|
||||
cwd: os.homedir(),
|
||||
cwd: resolvedProjectPath,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { userDb, appConfigDb } from '../database/db.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
// Use env var if set, otherwise auto-generate a unique secret per installation
|
||||
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
|
||||
|
||||
// Optional API key middleware
|
||||
const validateApiKey = (req, res, next) => {
|
||||
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
|
||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||
}
|
||||
|
||||
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
|
||||
if (decoded.exp && decoded.iat) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const halfLife = (decoded.exp - decoded.iat) / 2;
|
||||
if (now > decoded.iat + halfLife) {
|
||||
const newToken = generateToken(user);
|
||||
res.setHeader('X-Refreshed-Token', newToken);
|
||||
}
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate JWT token (never expires)
|
||||
// Generate JWT token
|
||||
const generateToken = (user) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username
|
||||
{
|
||||
userId: user.id,
|
||||
username: user.username
|
||||
},
|
||||
JWT_SECRET
|
||||
// No expiration - token lasts forever
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,7 +111,12 @@ const authenticateWebSocket = (token) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
// Verify user actually exists in database (matches REST authenticateToken behavior)
|
||||
const user = userDb.getUserById(decoded.userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return { userId: user.id, username: user.username };
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
export const CLAUDE_MODELS = {
|
||||
// Models in SDK format (what the actual SDK accepts)
|
||||
OPTIONS: [
|
||||
{ value: 'sonnet', label: 'Sonnet' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'haiku', label: 'Haiku' },
|
||||
{ value: 'opusplan', label: 'Opus Plan' },
|
||||
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
|
||||
{ value: "sonnet", label: "Sonnet" },
|
||||
{ value: "opus", label: "Opus" },
|
||||
{ value: "haiku", label: "Haiku" },
|
||||
{ value: "opusplan", label: "Opus Plan" },
|
||||
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
|
||||
],
|
||||
|
||||
DEFAULT: 'sonnet'
|
||||
DEFAULT: "sonnet",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -28,28 +28,28 @@ export const CLAUDE_MODELS = {
|
||||
*/
|
||||
export const CURSOR_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3' },
|
||||
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
|
||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
||||
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
|
||||
{ value: 'composer-1', label: 'Composer 1' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
|
||||
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
|
||||
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
|
||||
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
|
||||
{ value: 'grok', label: 'Grok' }
|
||||
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
|
||||
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
|
||||
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
|
||||
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
|
||||
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
|
||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||
{ value: "gpt-5.1", label: "GPT-5.1" },
|
||||
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
|
||||
{ value: "composer-1", label: "Composer 1" },
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
|
||||
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
|
||||
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
|
||||
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
|
||||
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
|
||||
{ value: "grok", label: "Grok" },
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5-3-codex'
|
||||
DEFAULT: "gpt-5-3-codex",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,17 +57,16 @@ export const CURSOR_MODELS = {
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||
{ 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' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'o3', label: 'O3' },
|
||||
{ value: 'o4-mini', label: 'O4-mini' }
|
||||
{ value: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ 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" },
|
||||
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||
{ value: "o3", label: "O3" },
|
||||
{ value: "o4-mini", label: "O4-mini" },
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5.4'
|
||||
DEFAULT: "gpt-5.4",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,16 +74,19 @@ export const CODEX_MODELS = {
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
|
||||
{ value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
|
||||
{ value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||
{
|
||||
value: "gemini-2.0-flash-thinking-exp",
|
||||
label: "Gemini 2.0 Flash Thinking",
|
||||
},
|
||||
],
|
||||
|
||||
DEFAULT: 'gemini-2.5-flash'
|
||||
DEFAULT: "gemini-2.5-flash",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type {
|
||||
@@ -9,10 +9,10 @@ import type {
|
||||
} from '../../types/types';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
import { Markdown } from './Markdown';
|
||||
import MessageCopyControl from './MessageCopyControl';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
@@ -20,7 +20,7 @@ type DiffLine = {
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface MessageComponentProps {
|
||||
type MessageComponentProps = {
|
||||
message: ChatMessage;
|
||||
prevMessage: ChatMessage | null;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
@@ -32,7 +32,7 @@ interface MessageComponentProps {
|
||||
showThinking?: boolean;
|
||||
selectedProject?: Project | null;
|
||||
provider: Provider | string;
|
||||
}
|
||||
};
|
||||
|
||||
type InteractiveOption = {
|
||||
number: string;
|
||||
@@ -41,6 +41,7 @@ type InteractiveOption = {
|
||||
};
|
||||
|
||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||
|
||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
@@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
|
||||
const [messageCopied, setMessageCopied] = React.useState(false);
|
||||
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||
const userCopyContent = String(message.content || '');
|
||||
const formattedMessageContent = useMemo(
|
||||
() => formatUsageLimitText(String(message.content || '')),
|
||||
[message.content]
|
||||
);
|
||||
const assistantCopyContent = message.isToolUse
|
||||
? String(message.displayText || message.content || '')
|
||||
: formattedMessageContent;
|
||||
const isCommandOrFileEditToolResponse = Boolean(
|
||||
message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
|
||||
);
|
||||
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
|
||||
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
|
||||
assistantCopyContent.trim().length > 0 &&
|
||||
!isCommandOrFileEditToolResponse;
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const node = messageRef.current;
|
||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||
|
||||
@@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const text = String(message.content || '');
|
||||
if (!text) return;
|
||||
|
||||
copyTextToClipboard(text).then((success) => {
|
||||
if (!success) return;
|
||||
setMessageCopied(true);
|
||||
});
|
||||
}}
|
||||
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
|
||||
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
|
||||
>
|
||||
{messageCopied ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{shouldShowUserCopyControl && (
|
||||
<MessageCopyControl content={userCopyContent} messageType="user" />
|
||||
)}
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -430,7 +411,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const content = formatUsageLimitText(String(message.content || ''));
|
||||
const content = formattedMessageContent;
|
||||
|
||||
// Detect if content is pure JSON (starts with { or [)
|
||||
const trimmedContent = content.trim();
|
||||
@@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGrouped && (
|
||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{formattedTime}
|
||||
{(shouldShowAssistantCopyControl || !isGrouped) && (
|
||||
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{shouldShowAssistantCopyControl && (
|
||||
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
|
||||
)}
|
||||
{!isGrouped && <span>{formattedTime}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT_MS = 2000;
|
||||
|
||||
type CopyFormat = 'text' | 'markdown';
|
||||
|
||||
type CopyFormatOption = {
|
||||
format: CopyFormat;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// Converts markdown into readable plain text for "Copy as text".
|
||||
const convertMarkdownToPlainText = (markdown: string): string => {
|
||||
let plainText = markdown.replace(/\r\n/g, '\n');
|
||||
const codeBlocks: string[] = [];
|
||||
plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => {
|
||||
const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;
|
||||
codeBlocks.push(code.replace(/\n$/, ''));
|
||||
return placeholder;
|
||||
});
|
||||
plainText = plainText.replace(/`([^`]+)`/g, '$1');
|
||||
plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1');
|
||||
plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
|
||||
plainText = plainText.replace(/^>\s?/gm, '');
|
||||
plainText = plainText.replace(/^#{1,6}\s+/gm, '');
|
||||
plainText = plainText.replace(/^[-*+]\s+/gm, '');
|
||||
plainText = plainText.replace(/^\d+\.\s+/gm, '');
|
||||
plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2');
|
||||
plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2');
|
||||
plainText = plainText.replace(/~~(.*?)~~/g, '$1');
|
||||
plainText = plainText.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
plainText = plainText.replace(/\n{3,}/g, '\n\n');
|
||||
plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');
|
||||
return plainText.trim();
|
||||
};
|
||||
|
||||
const MessageCopyControl = ({
|
||||
content,
|
||||
messageType,
|
||||
}: {
|
||||
content: string;
|
||||
messageType: 'user' | 'assistant';
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const canSelectCopyFormat = messageType === 'assistant';
|
||||
const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text';
|
||||
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
format: 'markdown',
|
||||
label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }),
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const selectedFormatTag = selectedFormat === 'markdown'
|
||||
? t('copyMessage.markdownShort', { defaultValue: 'MD' })
|
||||
: t('copyMessage.textShort', { defaultValue: 'TXT' });
|
||||
|
||||
const copyPayload = useMemo(() => {
|
||||
if (selectedFormat === 'markdown') {
|
||||
return content;
|
||||
}
|
||||
return convertMarkdownToPlainText(content);
|
||||
}, [content, selectedFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFormat(defaultFormat);
|
||||
setIsDropdownOpen(false);
|
||||
}, [defaultFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
// Close the dropdown when clicking anywhere outside this control.
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (!isDropdownOpen) return;
|
||||
const target = event.target as Node;
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', closeOnOutsideClick);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyFeedbackTimerRef.current) {
|
||||
clearTimeout(copyFeedbackTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopyClick = async () => {
|
||||
if (!copyPayload.trim()) return;
|
||||
const didCopy = await copyTextToClipboard(copyPayload);
|
||||
if (!didCopy) return;
|
||||
|
||||
setCopied(true);
|
||||
if (copyFeedbackTimerRef.current) {
|
||||
clearTimeout(copyFeedbackTimerRef.current);
|
||||
}
|
||||
copyFeedbackTimerRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, COPY_SUCCESS_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const handleFormatChange = (format: CopyFormat) => {
|
||||
setSelectedFormat(format);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
const toneClass = messageType === 'user'
|
||||
? 'text-blue-100 hover:text-white'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300';
|
||||
const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy');
|
||||
const rootClassName = canSelectCopyFormat
|
||||
? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto'
|
||||
: 'relative flex items-center gap-0.5';
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={rootClassName}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyClick}
|
||||
title={copyTitle}
|
||||
aria-label={copyTitle}
|
||||
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide">{selectedFormatTag}</span>
|
||||
</button>
|
||||
|
||||
{canSelectCopyFormat && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||
>
|
||||
<svg
|
||||
className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
{copyFormatOptions.map((option) => {
|
||||
const isSelected = option.format === selectedFormat;
|
||||
return (
|
||||
<button
|
||||
key={option.format}
|
||||
type="button"
|
||||
onClick={() => handleFormatChange(option.format)}
|
||||
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-medium">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageCopyControl;
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
|
||||
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import { NextTaskBanner } from '../../../task-master';
|
||||
import React from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
CURSOR_MODELS,
|
||||
CODEX_MODELS,
|
||||
GEMINI_MODELS,
|
||||
} from "../../../../../shared/modelConstants";
|
||||
import type { ProjectSession, SessionProvider } from "../../../../types/app";
|
||||
import { NextTaskBanner } from "../../../task-master";
|
||||
|
||||
interface ProviderSelectionEmptyStateProps {
|
||||
type ProviderSelectionEmptyStateProps = {
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
provider: SessionProvider;
|
||||
@@ -24,7 +29,7 @@ interface ProviderSelectionEmptyStateProps {
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
};
|
||||
|
||||
type ProviderDef = {
|
||||
id: SessionProvider;
|
||||
@@ -37,50 +42,56 @@ type ProviderDef = {
|
||||
|
||||
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: "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: "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: "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',
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
function getModelConfig(p: SessionProvider) {
|
||||
if (p === 'claude') return CLAUDE_MODELS;
|
||||
if (p === 'codex') return CODEX_MODELS;
|
||||
if (p === 'gemini') return GEMINI_MODELS;
|
||||
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, c: string, cu: string, co: string, g: string) {
|
||||
if (p === 'claude') return c;
|
||||
if (p === 'codex') return co;
|
||||
if (p === 'gemini') return g;
|
||||
function getModelValue(
|
||||
p: SessionProvider,
|
||||
c: string,
|
||||
cu: string,
|
||||
co: string,
|
||||
g: string,
|
||||
) {
|
||||
if (p === "claude") return c;
|
||||
if (p === "codex") return co;
|
||||
if (p === "gemini") return g;
|
||||
return cu;
|
||||
}
|
||||
|
||||
@@ -103,24 +114,41 @@ export default function ProviderSelectionEmptyState({
|
||||
onShowAllTasks,
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
|
||||
const { t } = useTranslation("chat");
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
});
|
||||
|
||||
const selectProvider = (next: SessionProvider) => {
|
||||
setProvider(next);
|
||||
localStorage.setItem('selected-provider', 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); }
|
||||
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(provider, claudeModel, cursorModel, codexModel, geminiModel);
|
||||
const currentModel = getModelValue(
|
||||
provider,
|
||||
claudeModel,
|
||||
cursorModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
);
|
||||
|
||||
/* ── New session — provider picker ── */
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
@@ -130,10 +158,10 @@ export default function ProviderSelectionEmptyState({
|
||||
{/* Heading */}
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||
{t('providerSelection.title')}
|
||||
{t("providerSelection.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
{t('providerSelection.description')}
|
||||
{t("providerSelection.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -149,23 +177,30 @@ export default function ProviderSelectionEmptyState({
|
||||
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'
|
||||
${
|
||||
active
|
||||
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
|
||||
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SessionProviderLogo
|
||||
provider={p.id}
|
||||
className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
|
||||
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
|
||||
/>
|
||||
<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)}</p>
|
||||
<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)}
|
||||
</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`}>
|
||||
<div
|
||||
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
|
||||
>
|
||||
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
@@ -175,9 +210,13 @@ export default function ProviderSelectionEmptyState({
|
||||
</div>
|
||||
|
||||
{/* 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={`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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("providerSelection.selectModel")}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentModel}
|
||||
@@ -185,9 +224,13 @@ export default function ProviderSelectionEmptyState({
|
||||
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} value={value}>{label}</option>
|
||||
))}
|
||||
{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>
|
||||
@@ -196,10 +239,18 @@ export default function ProviderSelectionEmptyState({
|
||||
<p className="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 }),
|
||||
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>
|
||||
@@ -208,7 +259,10 @@ export default function ProviderSelectionEmptyState({
|
||||
{/* Task banner */}
|
||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-5">
|
||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput(nextTaskPrompt)}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,12 +275,19 @@ export default function ProviderSelectionEmptyState({
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-md px-6 text-center">
|
||||
<p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
|
||||
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||
{t("session.continue.title")}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t("session.continue.description")}
|
||||
</p>
|
||||
|
||||
{tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-5">
|
||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput(nextTaskPrompt)}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "Copy message",
|
||||
"copied": "Message copied"
|
||||
"copied": "Message copied",
|
||||
"selectFormat": "Select copy format",
|
||||
"copyAsMarkdown": "Copy as markdown",
|
||||
"copyAsText": "Copy as text"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "メッセージをコピー",
|
||||
"copied": "メッセージをコピーしました"
|
||||
"copied": "メッセージをコピーしました",
|
||||
"selectFormat": "コピー形式を選択",
|
||||
"copyAsMarkdown": "Markdownとしてコピー",
|
||||
"copyAsText": "テキストとしてコピー"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "메시지 복사",
|
||||
"copied": "메시지 복사됨"
|
||||
"copied": "메시지 복사됨",
|
||||
"selectFormat": "복사 형식 선택",
|
||||
"copyAsMarkdown": "마크다운으로 복사",
|
||||
"copyAsText": "텍스트로 복사"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "Копировать сообщение",
|
||||
"copied": "Сообщение скопировано"
|
||||
"copied": "Сообщение скопировано",
|
||||
"selectFormat": "Выбрать формат копирования",
|
||||
"copyAsMarkdown": "Копировать как Markdown",
|
||||
"copyAsText": "Копировать как текст"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "П",
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "复制消息",
|
||||
"copied": "消息已复制"
|
||||
"copied": "消息已复制",
|
||||
"selectFormat": "选择复制格式",
|
||||
"copyAsMarkdown": "复制为 Markdown",
|
||||
"copyAsText": "复制为纯文本"
|
||||
},
|
||||
"messageTypes": {
|
||||
"user": "U",
|
||||
|
||||
@@ -21,6 +21,12 @@ export const authenticatedFetch = (url, options = {}) => {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
}).then((response) => {
|
||||
const refreshedToken = response.headers.get('X-Refreshed-Token');
|
||||
if (refreshedToken) {
|
||||
localStorage.setItem('auth-token', refreshedToken);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user