mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-04 21:47:43 +00:00
Compare commits
8 Commits
v1.22.0
...
style/impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20832fccb8 | ||
|
|
62d0b9f7f2 | ||
|
|
453a1452bb | ||
|
|
b0a3fdf95f | ||
|
|
4ee88f0eb0 | ||
|
|
688d73477a | ||
|
|
198e3da89b | ||
|
|
4da27ae5f1 |
22
.github/workflows/discord-release.yml
vendored
Normal file
22
.github/workflows/discord-release.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Discord Release Notification
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
github-releases-to-discord:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Github Releases To Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1.19.0
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
color: "2105893"
|
||||
username: "Release Changelog"
|
||||
avatar_url: "https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png"
|
||||
content: "||@everyone||"
|
||||
footer_title: "Changelog"
|
||||
reduce_headings: true
|
||||
@@ -34,7 +34,7 @@ function createRequestId() {
|
||||
}
|
||||
|
||||
function waitForToolApproval(requestId, options = {}) {
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
@@ -78,9 +78,14 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
pendingToolApprovals.set(requestId, (decision) => {
|
||||
const resolver = (decision) => {
|
||||
finalize(decision);
|
||||
});
|
||||
};
|
||||
// Attach metadata for getPendingApprovalsForSession lookup
|
||||
if (metadata) {
|
||||
Object.assign(resolver, metadata);
|
||||
}
|
||||
pendingToolApprovals.set(requestId, resolver);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,13 +214,14 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
* @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
|
||||
* @param {string} tempDir - Temp directory for cleanup
|
||||
*/
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
|
||||
function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
|
||||
activeSessions.set(sessionId, {
|
||||
instance: queryInstance,
|
||||
startTime: Date.now(),
|
||||
status: 'active',
|
||||
tempImagePaths,
|
||||
tempDir
|
||||
tempDir,
|
||||
writer
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,6 +518,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
signal: context?.signal,
|
||||
metadata: {
|
||||
_sessionId: capturedSessionId || sessionId || null,
|
||||
_toolName: toolName,
|
||||
_input: input,
|
||||
_receivedAt: new Date(),
|
||||
},
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
type: 'claude-permission-cancelled',
|
||||
@@ -562,7 +574,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
|
||||
// Track the query instance for abort capability
|
||||
if (capturedSessionId) {
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
}
|
||||
|
||||
// Process streaming messages
|
||||
@@ -572,7 +584,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
if (message.session_id && !capturedSessionId) {
|
||||
|
||||
capturedSessionId = message.session_id;
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
|
||||
|
||||
// Set session ID on writer
|
||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||
@@ -712,11 +724,50 @@ function getActiveClaudeSDKSessions() {
|
||||
return getAllSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tool approvals for a specific session.
|
||||
* @param {string} sessionId - The session ID
|
||||
* @returns {Array} Array of pending permission request objects
|
||||
*/
|
||||
function getPendingApprovalsForSession(sessionId) {
|
||||
const pending = [];
|
||||
for (const [requestId, resolver] of pendingToolApprovals.entries()) {
|
||||
if (resolver._sessionId === sessionId) {
|
||||
pending.push({
|
||||
requestId,
|
||||
toolName: resolver._toolName || 'UnknownTool',
|
||||
input: resolver._input,
|
||||
context: resolver._context,
|
||||
sessionId,
|
||||
receivedAt: resolver._receivedAt || new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect a session's WebSocketWriter to a new raw WebSocket.
|
||||
* Called when client reconnects (e.g. page refresh) while SDK is still running.
|
||||
* @param {string} sessionId - The session ID
|
||||
* @param {Object} newRawWs - The new raw WebSocket connection
|
||||
* @returns {boolean} True if writer was successfully reconnected
|
||||
*/
|
||||
function reconnectSessionWriter(sessionId, newRawWs) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session?.writer?.updateWebSocket) return false;
|
||||
session.writer.updateWebSocket(newRawWs);
|
||||
console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Export public API
|
||||
export {
|
||||
queryClaudeSDK,
|
||||
abortClaudeSDKSession,
|
||||
isClaudeSDKSessionActive,
|
||||
getActiveClaudeSDKSessions,
|
||||
resolveToolApproval
|
||||
resolveToolApproval,
|
||||
getPendingApprovalsForSession,
|
||||
reconnectSessionWriter
|
||||
};
|
||||
|
||||
@@ -91,6 +91,18 @@ const runMigrations = () => {
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
// 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)');
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
@@ -348,6 +360,60 @@ const credentialsDb = {
|
||||
}
|
||||
};
|
||||
|
||||
// Session custom names database operations
|
||||
const sessionNamesDb = {
|
||||
// Set (insert or update) a custom session name
|
||||
setName: (sessionId, provider, customName) => {
|
||||
db.prepare(`
|
||||
INSERT INTO session_names (session_id, provider, custom_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id, provider)
|
||||
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
|
||||
`).run(sessionId, provider, customName);
|
||||
},
|
||||
|
||||
// Get a single custom session name
|
||||
getName: (sessionId, provider) => {
|
||||
const row = db.prepare(
|
||||
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).get(sessionId, provider);
|
||||
return row?.custom_name || null;
|
||||
},
|
||||
|
||||
// Batch lookup — returns Map<sessionId, customName>
|
||||
getNames: (sessionIds, provider) => {
|
||||
if (!sessionIds.length) return new Map();
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT session_id, custom_name FROM session_names
|
||||
WHERE session_id IN (${placeholders}) AND provider = ?`
|
||||
).all(...sessionIds, provider);
|
||||
return new Map(rows.map(r => [r.session_id, r.custom_name]));
|
||||
},
|
||||
|
||||
// Delete a custom session name
|
||||
deleteName: (sessionId, provider) => {
|
||||
return db.prepare(
|
||||
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
|
||||
).run(sessionId, provider).changes > 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Apply custom session names from the database (overrides CLI-generated summaries)
|
||||
function applyCustomSessionNames(sessions, provider) {
|
||||
if (!sessions?.length) return;
|
||||
try {
|
||||
const ids = sessions.map(s => s.id);
|
||||
const customNames = sessionNamesDb.getNames(ids, provider);
|
||||
for (const session of sessions) {
|
||||
const custom = customNames.get(session.id);
|
||||
if (custom) session.summary = custom;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
@@ -373,5 +439,7 @@ export {
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
sessionNamesDb,
|
||||
applyCustomSessionNames,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
@@ -49,4 +49,17 @@ CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
|
||||
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);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
-- 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);
|
||||
@@ -45,7 +45,7 @@ import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
||||
@@ -64,10 +64,12 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];
|
||||
|
||||
// File system watchers for provider project/session folders
|
||||
const PROVIDER_WATCH_PATHS = [
|
||||
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
||||
@@ -493,6 +495,7 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re
|
||||
try {
|
||||
const { limit = 5, offset = 0 } = req.query;
|
||||
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
|
||||
applyCustomSessionNames(result.sessions, 'claude');
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -541,6 +544,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
||||
const { projectName, sessionId } = req.params;
|
||||
console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);
|
||||
await deleteSession(projectName, sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'claude');
|
||||
console.log(`[API] Session ${sessionId} deleted successfully`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -549,6 +553,32 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
||||
}
|
||||
});
|
||||
|
||||
// Rename session endpoint
|
||||
app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
const { summary, provider } = req.body;
|
||||
if (!summary || typeof summary !== 'string' || summary.trim() === '') {
|
||||
return res.status(400).json({ error: 'Summary is required' });
|
||||
}
|
||||
if (summary.trim().length > 500) {
|
||||
return res.status(400).json({ error: 'Summary must not exceed 500 characters' });
|
||||
}
|
||||
if (!provider || !VALID_PROVIDERS.includes(provider)) {
|
||||
return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });
|
||||
}
|
||||
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete project endpoint (force=true to delete with sessions)
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -1350,6 +1380,10 @@ class WebSocketWriter {
|
||||
}
|
||||
}
|
||||
|
||||
updateWebSocket(newRawWs) {
|
||||
this.ws = newRawWs;
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
@@ -1464,6 +1498,11 @@ function handleChatConnection(ws) {
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
isActive = isClaudeSDKSessionActive(sessionId);
|
||||
if (isActive) {
|
||||
// Reconnect the session's writer to the new WebSocket so
|
||||
// subsequent SDK output flows to the refreshed client.
|
||||
reconnectSessionWriter(sessionId, ws);
|
||||
}
|
||||
}
|
||||
|
||||
writer.send({
|
||||
@@ -1472,6 +1511,17 @@ function handleChatConnection(ws) {
|
||||
provider,
|
||||
isProcessing: isActive
|
||||
});
|
||||
} else if (data.type === 'get-pending-permissions') {
|
||||
// Return pending permission requests for a session
|
||||
const sessionId = data.sessionId;
|
||||
if (sessionId && isClaudeSDKSessionActive(sessionId)) {
|
||||
const pending = getPendingApprovalsForSession(sessionId);
|
||||
writer.send({
|
||||
type: 'pending-permissions-response',
|
||||
sessionId,
|
||||
data: pending
|
||||
});
|
||||
}
|
||||
} else if (data.type === 'get-active-sessions') {
|
||||
// Get all currently active sessions
|
||||
const activeSessions = {
|
||||
@@ -2112,7 +2162,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import os from 'os';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import { applyCustomSessionNames } from './database/db.js';
|
||||
|
||||
// Import TaskMaster detection functions
|
||||
async function detectTaskMasterFolder(projectPath) {
|
||||
@@ -458,6 +459,7 @@ async function getProjects(progressCallback = null) {
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
applyCustomSessionNames(project.sessions, 'claude');
|
||||
|
||||
// Also fetch Cursor sessions for this project
|
||||
try {
|
||||
@@ -466,6 +468,7 @@ async function getProjects(progressCallback = null) {
|
||||
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
@@ -476,6 +479,7 @@ async function getProjects(progressCallback = null) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Also fetch Gemini sessions for this project
|
||||
try {
|
||||
@@ -484,6 +488,7 @@ async function getProjects(progressCallback = null) {
|
||||
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||
project.geminiSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
// Add TaskMaster detection
|
||||
try {
|
||||
@@ -567,6 +572,7 @@ async function getProjects(progressCallback = null) {
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
@@ -576,6 +582,7 @@ async function getProjects(progressCallback = null) {
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Try to fetch Gemini sessions for manual projects too
|
||||
try {
|
||||
@@ -583,6 +590,7 @@ async function getProjects(progressCallback = null) {
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
// Add TaskMaster detection for manual projects
|
||||
try {
|
||||
@@ -1071,10 +1079,13 @@ async function renameProject(projectName, newDisplayName) {
|
||||
|
||||
if (!newDisplayName || newDisplayName.trim() === '') {
|
||||
// Remove custom name if empty, will fall back to auto-generated
|
||||
delete config[projectName];
|
||||
if (config[projectName]) {
|
||||
delete config[projectName].displayName;
|
||||
}
|
||||
} else {
|
||||
// Set custom display name
|
||||
// Set custom display name, preserving other properties (manuallyAdded, originalPath)
|
||||
config[projectName] = {
|
||||
...config[projectName],
|
||||
displayName: newDisplayName.trim()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ router.get('/claude/status', async (req, res) => {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: 'credentials_file'
|
||||
method: credentialsResult.method // 'api_key' or 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ router.get('/claude/status', async (req, res) => {
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
@@ -115,6 +117,20 @@ router.get('/gemini/status', async (req, res) => {
|
||||
* - 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 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');
|
||||
@@ -127,19 +143,22 @@ async function checkClaudeCredentials() {
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
||||
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -59,6 +60,7 @@ router.get('/sessions', async (req, res) => {
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
applyCustomSessionNames(sessions, 'codex');
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
@@ -88,6 +90,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'codex');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
import { applyCustomSessionNames } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -560,8 +561,10 @@ router.get('/sessions', async (req, res) => {
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
applyCustomSessionNames(sessions, 'cursor');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessions: sessions,
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import sessionManager from '../sessionManager.js';
|
||||
import { sessionNamesDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -36,6 +37,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
}
|
||||
|
||||
await sessionManager.deleteSession(sessionId);
|
||||
sessionNamesDb.deleteName(sessionId, 'gemini');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -16,7 +16,8 @@ export default function AppContent() {
|
||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||
const { t } = useTranslation('common');
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
const { ws, sendMessage, latestMessage } = useWebSocket();
|
||||
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const {
|
||||
activeSessions,
|
||||
@@ -71,6 +72,24 @@ export default function AppContent() {
|
||||
};
|
||||
}, [openSettings]);
|
||||
|
||||
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
||||
useEffect(() => {
|
||||
const isReconnect = isConnected && !wasConnectedRef.current;
|
||||
|
||||
if (isReconnect) {
|
||||
wasConnectedRef.current = true;
|
||||
} else if (!isConnected) {
|
||||
wasConnectedRef.current = false;
|
||||
}
|
||||
|
||||
if (isConnected && selectedSession?.id) {
|
||||
sendMessage({
|
||||
type: 'get-pending-permissions',
|
||||
sessionId: selectedSession.id
|
||||
});
|
||||
}
|
||||
}, [isConnected, selectedSession?.id, sendMessage]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{!isMobile ? (
|
||||
|
||||
@@ -1080,6 +1080,19 @@ export function useChatRealtimeHandlers({
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pending-permissions-response': {
|
||||
// Server returned pending permissions for this session
|
||||
const permSessionId = latestMessage.sessionId;
|
||||
const isCurrentPermSession =
|
||||
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
|
||||
if (permSessionId && !isCurrentPermSession) {
|
||||
break;
|
||||
}
|
||||
const serverRequests = latestMessage.data || [];
|
||||
setPendingPermissionRequests(serverRequests);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function useChatSessionState({
|
||||
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
||||
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastLoadedSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
||||
|
||||
@@ -297,10 +298,15 @@ export function useChatSessionState({
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, [chatMessages.length]);
|
||||
|
||||
const prevSessionMessagesLengthRef = useRef(0);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
pendingInitialScrollRef.current = true;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
prevSessionMessagesLengthRef.current = 0;
|
||||
isInitialLoadRef.current = true;
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.name, selectedSession?.id]);
|
||||
@@ -373,6 +379,15 @@ export function useChatSessionState({
|
||||
}
|
||||
}
|
||||
|
||||
// Skip loading if session+project+provider hasn't changed
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`;
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey) {
|
||||
setTimeout(() => {
|
||||
isLoadingSessionRef.current = false;
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
@@ -400,6 +415,9 @@ export function useChatSessionState({
|
||||
setIsSystemSessionChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last loaded session key
|
||||
lastLoadedSessionKeyRef.current = sessionKey;
|
||||
} else {
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
@@ -417,6 +435,7 @@ export function useChatSessionState({
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
lastLoadedSessionKeyRef.current = null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -433,7 +452,7 @@ export function useChatSessionState({
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
selectedSession?.id, // Only depend on session ID, not the entire object
|
||||
sendMessage,
|
||||
ws,
|
||||
]);
|
||||
@@ -490,11 +509,24 @@ export function useChatSessionState({
|
||||
}
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionMessages.length > 0) {
|
||||
setChatMessages(convertedMessages);
|
||||
// Only sync sessionMessages to chatMessages when:
|
||||
// 1. Not currently loading (to avoid overwriting user's just-sent message)
|
||||
// 2. SessionMessages actually changed (including from non-empty to empty)
|
||||
// 3. Either it's initial load OR sessionMessages increased (new messages from server)
|
||||
if (
|
||||
sessionMessages.length !== prevSessionMessagesLengthRef.current &&
|
||||
!isLoading
|
||||
) {
|
||||
// Only update if this is initial load, sessionMessages grew, or was cleared to empty
|
||||
if (isInitialLoadRef.current || sessionMessages.length === 0 || sessionMessages.length > prevSessionMessagesLengthRef.current) {
|
||||
setChatMessages(convertedMessages);
|
||||
isInitialLoadRef.current = false;
|
||||
}
|
||||
prevSessionMessagesLengthRef.current = sessionMessages.length;
|
||||
}
|
||||
}, [convertedMessages, sessionMessages.length]);
|
||||
}, [convertedMessages, sessionMessages.length, isLoading, setChatMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject && chatMessages.length > 0) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
@@ -12,33 +14,60 @@ type ClaudeStatusProps = {
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const SPINNER_CHARS = ['*', '+', 'x', '.'];
|
||||
const ACTION_KEYS = [
|
||||
'claudeStatus.actions.thinking',
|
||||
'claudeStatus.actions.processing',
|
||||
'claudeStatus.actions.analyzing',
|
||||
'claudeStatus.actions.working',
|
||||
'claudeStatus.actions.computing',
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const ANIMATION_STEPS = 40;
|
||||
|
||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
claude: 'messageTypes.claude',
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes < 1) {
|
||||
return t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' });
|
||||
}
|
||||
|
||||
return t('claudeStatus.elapsed.minutesSeconds', {
|
||||
minutes,
|
||||
seconds,
|
||||
defaultValue: '{{minutes}}m {{seconds}}s',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ClaudeStatus({
|
||||
status,
|
||||
onAbort,
|
||||
isLoading,
|
||||
provider: _provider = 'claude',
|
||||
provider = 'claude',
|
||||
}: ClaudeStatusProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
setFakeTokens(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const tokenRate = 30 + Math.random() * 20;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
setFakeTokens(Math.floor(elapsed * tokenRate));
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
@@ -50,68 +79,118 @@ export default function ClaudeStatus({
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
|
||||
setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);
|
||||
}, 500);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
if (!isLoading) {
|
||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
||||
if (!isLoading && !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
|
||||
const statusText = status?.text || ACTION_WORDS[actionIndex];
|
||||
const tokens = status?.tokens || fakeTokens;
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
const currentSpinner = SPINNER_CHARS[animationPhase];
|
||||
const actionWords = ACTION_KEYS.map((key, index) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[index] }));
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const cleanStatusText = statusText.replace(/[.]+$/, '');
|
||||
const canInterrupt = isLoading && status?.can_interrupt !== false;
|
||||
const providerLabelKey = PROVIDER_LABEL_KEYS[provider];
|
||||
const providerLabel = providerLabelKey
|
||||
? t(providerLabelKey)
|
||||
: t('claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
||||
const animatedDots = '.'.repeat((animationPhase % 3) + 1);
|
||||
const elapsedLabel =
|
||||
elapsedTime > 0
|
||||
? t('claudeStatus.elapsed.label', {
|
||||
time: formatElapsedTime(elapsedTime, t),
|
||||
defaultValue: '{{time}} elapsed',
|
||||
})
|
||||
: t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });
|
||||
|
||||
return (
|
||||
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
|
||||
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
|
||||
)}
|
||||
>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
<div className="relative max-w-4xl mx-auto overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">
|
||||
tokens {tokens.toLocaleString()}
|
||||
<div className="relative px-3 py-3 sm:px-4 sm:py-3.5">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3" role="status" aria-live="polite">
|
||||
<div className="relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-primary/25 bg-primary/10">
|
||||
<SessionProviderLogo provider={provider} className="h-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5">
|
||||
{isLoading && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/70" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2.5 w-2.5 rounded-full',
|
||||
isLoading ? 'bg-emerald-400' : 'bg-amber-400',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
<span>{providerLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[9px] tracking-[0.14em]',
|
||||
isLoading
|
||||
? 'bg-emerald-500/15 text-emerald-500 dark:text-emerald-400'
|
||||
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? t('claudeStatus.state.live', { defaultValue: 'Live' })
|
||||
: t('claudeStatus.state.paused', { defaultValue: 'Paused' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-sm font-semibold text-foreground sm:text-[15px]">
|
||||
{cleanStatusText}
|
||||
{isLoading && (
|
||||
<span aria-hidden="true" className="text-primary">
|
||||
{animatedDots}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex items-center -ml-2 rounded-full border border-border/70 bg-background/60 px-2 py-0.5"
|
||||
>
|
||||
{elapsedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canInterrupt && onAbort && (
|
||||
<div className="w-full sm:w-auto sm:text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 active:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 sm:w-auto"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>{t('claudeStatus.controls.stopGeneration', { defaultValue: 'Stop Generation' })}</span>
|
||||
<span className="rounded-md bg-black/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-destructive-foreground/95">
|
||||
Esc
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-1 hidden text-[11px] text-muted-foreground sm:block">
|
||||
{t('claudeStatus.controls.pressEscToStop', { defaultValue: 'Press Esc anytime to stop' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ type StatusApiResponse = {
|
||||
authenticated?: boolean;
|
||||
email?: string | null;
|
||||
error?: string | null;
|
||||
method?: string;
|
||||
};
|
||||
|
||||
type JsonResult = {
|
||||
@@ -267,6 +268,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
||||
email: data.email || null,
|
||||
loading: false,
|
||||
error: data.error || null,
|
||||
method: data.method,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error checking ${provider} auth status:`, error);
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AuthStatus = {
|
||||
email: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
method?: string;
|
||||
};
|
||||
|
||||
export type KeyValueMap = Record<string, string>;
|
||||
|
||||
@@ -107,28 +107,30 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus.authenticated
|
||||
? t('agents.login.reAuthDescription')
|
||||
: t('agents.login.description', { agent: config.name })}
|
||||
{authStatus.method !== 'api_key' && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className={`font-medium ${config.textClass}`}>
|
||||
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
||||
</div>
|
||||
<div className={`text-sm ${config.subtextClass}`}>
|
||||
{authStatus.authenticated
|
||||
? t('agents.login.reAuthDescription')
|
||||
: t('agents.login.description', { agent: config.name })}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onLogin}
|
||||
className={`${config.buttonClass} text-white`}
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authStatus.error && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
|
||||
@@ -149,6 +149,8 @@ export function useShellRuntime({
|
||||
|
||||
return {
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
wsRef,
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
|
||||
@@ -61,6 +61,8 @@ export type ShellSharedRefs = {
|
||||
|
||||
export type UseShellRuntimeResult = {
|
||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
isConnected: boolean;
|
||||
isInitialized: boolean;
|
||||
isConnecting: boolean;
|
||||
|
||||
@@ -9,6 +9,7 @@ import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
|
||||
import ShellEmptyState from './subcomponents/ShellEmptyState';
|
||||
import ShellHeader from './subcomponents/ShellHeader';
|
||||
import ShellMinimalView from './subcomponents/ShellMinimalView';
|
||||
import TerminalShortcutsPanel from './subcomponents/TerminalShortcutsPanel';
|
||||
|
||||
type ShellProps = {
|
||||
selectedProject?: Project | null;
|
||||
@@ -39,6 +40,8 @@ export default function Shell({
|
||||
|
||||
const {
|
||||
terminalContainerRef,
|
||||
terminalRef,
|
||||
wsRef,
|
||||
isConnected,
|
||||
isInitialized,
|
||||
isConnecting,
|
||||
@@ -157,6 +160,12 @@ export default function Shell({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TerminalShortcutsPanel
|
||||
wsRef={wsRef}
|
||||
terminalRef={terminalRef}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Keyboard,
|
||||
ArrowDownToLine,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { sendSocketMessage } from '../../utils/socket';
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ id: 'escape', labelKey: 'escape', sequence: '\x1b', hint: 'Esc' },
|
||||
{ id: 'tab', labelKey: 'tab', sequence: '\t', hint: 'Tab' },
|
||||
{ id: 'shift-tab', labelKey: 'shiftTab', sequence: '\x1b[Z', hint: '\u21e7Tab' },
|
||||
{ id: 'arrow-up', labelKey: 'arrowUp', sequence: '\x1b[A', hint: '\u2191' },
|
||||
{ id: 'arrow-down', labelKey: 'arrowDown', sequence: '\x1b[B', hint: '\u2193' },
|
||||
] as const;
|
||||
|
||||
type TerminalShortcutsPanelProps = {
|
||||
wsRef: MutableRefObject<WebSocket | null>;
|
||||
terminalRef: MutableRefObject<Terminal | null>;
|
||||
isConnected: boolean;
|
||||
};
|
||||
|
||||
const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault();
|
||||
|
||||
export default function TerminalShortcutsPanel({
|
||||
wsRef,
|
||||
terminalRef,
|
||||
isConnected,
|
||||
}: TerminalShortcutsPanelProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleShortcutAction = useCallback((action: () => void) => {
|
||||
action();
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 50);
|
||||
}, []);
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
sendSocketMessage(wsRef.current, { type: 'input', data });
|
||||
},
|
||||
[wsRef],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
terminalRef.current?.scrollToBottom();
|
||||
}, [terminalRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab */}
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={preventFocusSteal}
|
||||
onClick={handleToggle}
|
||||
className={`fixed ${
|
||||
isOpen ? 'right-64' : 'right-0'
|
||||
} z-50 transition-all duration-150 ease-out bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 shadow-lg cursor-pointer`}
|
||||
style={{ top: '50%', transform: 'translateY(-50%)' }}
|
||||
aria-label={
|
||||
isOpen
|
||||
? t('terminalShortcuts.handle.closePanel')
|
||||
: t('terminalShortcuts.handle.openPanel')
|
||||
}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
{t('terminalShortcuts.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content — conditionally rendered so buttons remount with clean CSS states */}
|
||||
{isOpen && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background">
|
||||
{/* Shortcut Keys */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('terminalShortcuts.sectionKeys')}
|
||||
</h4>
|
||||
{SHORTCUTS.map((shortcut) => (
|
||||
<button
|
||||
type="button"
|
||||
key={shortcut.id}
|
||||
onPointerDown={preventFocusSteal}
|
||||
onClick={() => handleShortcutAction(() => sendInput(shortcut.sequence))}
|
||||
disabled={!isConnected}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
||||
>
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{t(`terminalShortcuts.${shortcut.labelKey}`)}
|
||||
</span>
|
||||
<kbd className="px-2 py-0.5 text-xs font-mono bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600">
|
||||
{shortcut.hint}
|
||||
</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
|
||||
{t('terminalShortcuts.sectionNavigation')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={preventFocusSteal}
|
||||
onClick={() => handleShortcutAction(scrollToBottom)}
|
||||
disabled={!isConnected}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600"
|
||||
>
|
||||
<span className="text-sm text-gray-900 dark:text-white">
|
||||
{t('terminalShortcuts.scrollDown')}
|
||||
</span>
|
||||
<ArrowDownToLine className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onPointerDown={preventFocusSteal}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||
import type {
|
||||
AdditionalSessionsByProject,
|
||||
DeleteProjectConfirmation,
|
||||
@@ -405,12 +405,30 @@ export function useSidebarController({
|
||||
}, [onRefresh]);
|
||||
|
||||
const updateSessionSummary = useCallback(
|
||||
async (_projectName: string, _sessionId: string, _summary: string) => {
|
||||
// Session rename endpoint is not currently exposed on the API.
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
async (_projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {
|
||||
const trimmed = summary.trim();
|
||||
if (!trimmed) {
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await api.renameSession(sessionId, trimmed, provider);
|
||||
if (response.ok) {
|
||||
await onRefresh();
|
||||
} else {
|
||||
console.error('[Sidebar] Failed to rename session:', response.status);
|
||||
alert(t('messages.renameSessionFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error renaming session:', error);
|
||||
alert(t('messages.renameSessionError'));
|
||||
} finally {
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
}
|
||||
},
|
||||
[],
|
||||
[onRefresh, t],
|
||||
);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||
|
||||
export const getSessionName = (session: SessionWithProvider, t: TFunction): string => {
|
||||
if (session.__provider === 'cursor') {
|
||||
return session.name || t('projects.untitledSession');
|
||||
return session.summary || session.name || t('projects.untitledSession');
|
||||
}
|
||||
|
||||
if (session.__provider === 'codex') {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import SidebarCollapsed from './subcomponents/SidebarCollapsed';
|
||||
import SidebarContent from './subcomponents/SidebarContent';
|
||||
import SidebarModals from './subcomponents/SidebarModals';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { Project, SessionProvider } from '../../../types/app';
|
||||
import type { SidebarProjectListProps } from './subcomponents/SidebarProjectList';
|
||||
import type { MCPServerStatus, SidebarProps } from '../types/types';
|
||||
|
||||
@@ -172,8 +172,8 @@ function Sidebar({
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
},
|
||||
onSaveEditingSession: (projectName, sessionId, summary) => {
|
||||
void updateSessionSummary(projectName, sessionId, summary);
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {
|
||||
void updateSessionSummary(projectName, sessionId, summary, provider);
|
||||
},
|
||||
touchHandlerFactory: handleTouchClick,
|
||||
t,
|
||||
|
||||
@@ -45,7 +45,7 @@ type SidebarProjectItemProps = {
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
onCancelEditingSession: () => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
|
||||
touchHandlerFactory: TouchHandlerFactory;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export type SidebarProjectListProps = {
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
onCancelEditingSession: () => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
|
||||
touchHandlerFactory: TouchHandlerFactory;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ type SidebarProjectSessionsProps = {
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
onCancelEditingSession: () => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||
onDeleteSession: (
|
||||
|
||||
@@ -19,7 +19,7 @@ type SidebarSessionItemProps = {
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
onCancelEditingSession: () => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string) => void;
|
||||
onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: SessionWithProvider, projectName: string) => void;
|
||||
onDeleteSession: (
|
||||
@@ -58,7 +58,7 @@ export default function SidebarSessionItem({
|
||||
};
|
||||
|
||||
const saveEditedSession = () => {
|
||||
onSaveEditingSession(project.name, session.id, editingSessionName);
|
||||
onSaveEditingSession(project.name, session.id, editingSessionName, session.__provider);
|
||||
};
|
||||
|
||||
const requestDeleteSession = () => {
|
||||
@@ -161,9 +161,8 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{!sessionView.isCursorSession && (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{editingSession === session.id && !sessionView.isCodexSession ? (
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{editingSession === session.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
@@ -204,32 +203,31 @@ export default function SidebarSessionItem({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!sessionView.isCodexSession && (
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartEditingSession(session.id, session.summary || t('projects.newSession'));
|
||||
}}
|
||||
title={t('tooltips.editSessionName')}
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
requestDeleteSession();
|
||||
onStartEditingSession(session.id, sessionView.sessionName);
|
||||
}}
|
||||
title={t('tooltips.deleteSession')}
|
||||
title={t('tooltips.editSessionName')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{!sessionView.isCursorSession && (
|
||||
<button
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
requestDeleteSession();
|
||||
}}
|
||||
title={t('tooltips.deleteSession')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -233,10 +233,37 @@
|
||||
"startCli": "Starting Claude CLI in {{projectName}}",
|
||||
"defaultCommand": "command"
|
||||
},
|
||||
"claudeStatus": {
|
||||
"actions": {
|
||||
"thinking": "Thinking",
|
||||
"processing": "Processing",
|
||||
"analyzing": "Analyzing",
|
||||
"working": "Working",
|
||||
"computing": "Computing",
|
||||
"reasoning": "Reasoning"
|
||||
},
|
||||
"state": {
|
||||
"live": "Live",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"elapsed": {
|
||||
"seconds": "{{count}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"label": "{{time}} elapsed",
|
||||
"startingNow": "Starting now"
|
||||
},
|
||||
"controls": {
|
||||
"stopGeneration": "Stop Generation",
|
||||
"pressEscToStop": "Press Esc anytime to stop"
|
||||
},
|
||||
"providers": {
|
||||
"assistant": "Assistant"
|
||||
}
|
||||
},
|
||||
"projectSelection": {
|
||||
"startChatWithProvider": "Select a project to start chatting with {{provider}}"
|
||||
},
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "Start the next task"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
"title": "Terminal Shortcuts",
|
||||
"sectionKeys": "Keys",
|
||||
"sectionNavigation": "Navigation",
|
||||
"escape": "Escape",
|
||||
"tab": "Tab",
|
||||
"shiftTab": "Shift+Tab",
|
||||
"arrowUp": "Arrow Up",
|
||||
"arrowDown": "Arrow Down",
|
||||
"scrollDown": "Scroll Down",
|
||||
"handle": {
|
||||
"closePanel": "Close shortcuts panel",
|
||||
"openPanel": "Open shortcuts panel"
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "Settings",
|
||||
"agents": "Agents",
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"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.",
|
||||
"createProjectFailed": "Failed to create project. Please try again.",
|
||||
|
||||
@@ -205,5 +205,32 @@
|
||||
"runCommand": "{{projectName}}で{{command}}を実行",
|
||||
"startCli": "{{projectName}}でClaude CLIを起動しています",
|
||||
"defaultCommand": "コマンド"
|
||||
},
|
||||
"claudeStatus": {
|
||||
"actions": {
|
||||
"thinking": "Thinking",
|
||||
"processing": "Processing",
|
||||
"analyzing": "Analyzing",
|
||||
"working": "Working",
|
||||
"computing": "Computing",
|
||||
"reasoning": "Reasoning"
|
||||
},
|
||||
"state": {
|
||||
"live": "Live",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"elapsed": {
|
||||
"seconds": "{{count}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"label": "{{time}} elapsed",
|
||||
"startingNow": "Starting now"
|
||||
},
|
||||
"controls": {
|
||||
"stopGeneration": "Stop Generation",
|
||||
"pressEscToStop": "Press Esc anytime to stop"
|
||||
},
|
||||
"providers": {
|
||||
"assistant": "Assistant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
"title": "ターミナルショートカット",
|
||||
"sectionKeys": "キー",
|
||||
"sectionNavigation": "ナビゲーション",
|
||||
"escape": "Escape",
|
||||
"tab": "Tab",
|
||||
"shiftTab": "Shift+Tab",
|
||||
"arrowUp": "上矢印",
|
||||
"arrowDown": "下矢印",
|
||||
"scrollDown": "下にスクロール",
|
||||
"handle": {
|
||||
"closePanel": "ショートカットパネルを閉じる",
|
||||
"openPanel": "ショートカットパネルを開く"
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "設定",
|
||||
"agents": "エージェント",
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"enterProjectPath": "プロジェクトのパスを入力してください",
|
||||
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
|
||||
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
|
||||
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
|
||||
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
|
||||
"deleteProjectFailed": "プロジェクトの削除に失敗しました。もう一度お試しください。",
|
||||
"deleteProjectError": "プロジェクトの削除でエラーが発生しました。もう一度お試しください。",
|
||||
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",
|
||||
|
||||
@@ -215,10 +215,37 @@
|
||||
"startCli": "{{projectName}}에서 Claude CLI 시작",
|
||||
"defaultCommand": "명령어"
|
||||
},
|
||||
"claudeStatus": {
|
||||
"actions": {
|
||||
"thinking": "Thinking",
|
||||
"processing": "Processing",
|
||||
"analyzing": "Analyzing",
|
||||
"working": "Working",
|
||||
"computing": "Computing",
|
||||
"reasoning": "Reasoning"
|
||||
},
|
||||
"state": {
|
||||
"live": "Live",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"elapsed": {
|
||||
"seconds": "{{count}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"label": "{{time}} elapsed",
|
||||
"startingNow": "Starting now"
|
||||
},
|
||||
"controls": {
|
||||
"stopGeneration": "Stop Generation",
|
||||
"pressEscToStop": "Press Esc anytime to stop"
|
||||
},
|
||||
"providers": {
|
||||
"assistant": "Assistant"
|
||||
}
|
||||
},
|
||||
"projectSelection": {
|
||||
"startChatWithProvider": "{{provider}}와 채팅을 시작하려면 프로젝트를 선택하세요"
|
||||
},
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "다음 작업 시작"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
"title": "터미널 단축키",
|
||||
"sectionKeys": "키",
|
||||
"sectionNavigation": "탐색",
|
||||
"escape": "Escape",
|
||||
"tab": "Tab",
|
||||
"shiftTab": "Shift+Tab",
|
||||
"arrowUp": "위쪽 화살표",
|
||||
"arrowDown": "아래쪽 화살표",
|
||||
"scrollDown": "아래로 스크롤",
|
||||
"handle": {
|
||||
"closePanel": "단축키 패널 닫기",
|
||||
"openPanel": "단축키 패널 열기"
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "설정",
|
||||
"agents": "에이전트",
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"enterProjectPath": "프로젝트 경로를 입력해주세요",
|
||||
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
|
||||
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
|
||||
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
|
||||
"deleteProjectFailed": "프로젝트 삭제 실패. 다시 시도해주세요.",
|
||||
"deleteProjectError": "프로젝트 삭제 오류. 다시 시도해주세요.",
|
||||
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
|
||||
|
||||
@@ -215,10 +215,37 @@
|
||||
"startCli": "在 {{projectName}} 中启动 Claude CLI",
|
||||
"defaultCommand": "命令"
|
||||
},
|
||||
"claudeStatus": {
|
||||
"actions": {
|
||||
"thinking": "Thinking",
|
||||
"processing": "Processing",
|
||||
"analyzing": "Analyzing",
|
||||
"working": "Working",
|
||||
"computing": "Computing",
|
||||
"reasoning": "Reasoning"
|
||||
},
|
||||
"state": {
|
||||
"live": "Live",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"elapsed": {
|
||||
"seconds": "{{count}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"label": "{{time}} elapsed",
|
||||
"startingNow": "Starting now"
|
||||
},
|
||||
"controls": {
|
||||
"stopGeneration": "Stop Generation",
|
||||
"pressEscToStop": "Press Esc anytime to stop"
|
||||
},
|
||||
"providers": {
|
||||
"assistant": "Assistant"
|
||||
}
|
||||
},
|
||||
"projectSelection": {
|
||||
"startChatWithProvider": "选择一个项目以开始与 {{provider}} 聊天"
|
||||
},
|
||||
"tasks": {
|
||||
"nextTaskPrompt": "开始下一个任务"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminalShortcuts": {
|
||||
"title": "终端快捷键",
|
||||
"sectionKeys": "按键",
|
||||
"sectionNavigation": "导航",
|
||||
"escape": "Escape",
|
||||
"tab": "Tab",
|
||||
"shiftTab": "Shift+Tab",
|
||||
"arrowUp": "上箭头",
|
||||
"arrowDown": "下箭头",
|
||||
"scrollDown": "滚动到底部",
|
||||
"handle": {
|
||||
"closePanel": "关闭快捷键面板",
|
||||
"openPanel": "打开快捷键面板"
|
||||
}
|
||||
},
|
||||
"mainTabs": {
|
||||
"label": "设置",
|
||||
"agents": "智能体",
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
"enterProjectPath": "请输入项目路径",
|
||||
"deleteSessionFailed": "删除会话失败,请重试。",
|
||||
"deleteSessionError": "删除会话时出错,请重试。",
|
||||
"renameSessionFailed": "重命名会话失败,请重试。",
|
||||
"renameSessionError": "重命名会话时出错,请重试。",
|
||||
"deleteProjectFailed": "删除项目失败,请重试。",
|
||||
"deleteProjectError": "删除项目时出错,请重试。",
|
||||
"createProjectFailed": "创建项目失败,请重试。",
|
||||
|
||||
@@ -77,6 +77,11 @@ export const api = {
|
||||
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
renameSession: (sessionId, summary, provider) =>
|
||||
authenticatedFetch(`/api/sessions/${sessionId}/rename`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ summary, provider }),
|
||||
}),
|
||||
deleteCodexSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
Reference in New Issue
Block a user