Compare commits

...

8 Commits

Author SHA1 Message Date
Haileyesus
20832fccb8 style: enhance accessibility for loading indicators in ClaudeStatus component 2026-03-04 20:43:57 +03:00
Haileyesus
62d0b9f7f2 style: improve UI for processing banner 2026-03-04 12:49:30 +03:00
Menny Even Danan
453a1452bb Add support for ANTHROPIC_API_KEY environment variable authentication detection (#346)
* Add support for ANTHROPIC_API_KEY environment variable authentication detection

This commit enhances Claude authentication detection to support both the
ANTHROPIC_API_KEY environment variable and the OAuth credentials file,
matching the authentication priority order used by the Claude Agent SDK.

- Updated checkClaudeCredentials() function in server/routes/cli-auth.js
  to check ANTHROPIC_API_KEY environment variable first, then fall back
  to ~/.claude/.credentials.json OAuth tokens

- Modified /api/cli-auth/claude/status endpoint to return authentication
  method indicator ('api_key' or 'credentials_file')

- Added comprehensive JSDoc documentation with priority order explanation
  and official Claude documentation citations

1. ANTHROPIC_API_KEY environment variable (highest priority)
2. ~/.claude/.credentials.json OAuth tokens (fallback)

This priority order matches the Claude Agent SDK's authentication behavior,
ensuring consistency between how we detect authentication and how the SDK
actually authenticates.

The /api/cli-auth/claude/status endpoint now returns:
- method: 'api_key' when using ANTHROPIC_API_KEY environment variable
- method: 'credentials_file' when using OAuth credentials file
- method: null when not authenticated

This is backward compatible as existing code checking the 'authenticated'
field will continue to work.

- https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
  Claude Agent SDK prioritizes environment variables over subscriptions

- https://platform.claude.com/docs/en/agent-sdk/overview
  Official Claude Agent SDK authentication documentation

When ANTHROPIC_API_KEY is set, API calls are charged via pay-as-you-go
rates instead of subscription rates, even if the user is logged in with
a claude.ai subscription.

* UI: hide Claude login when API key auth is used

An API key overrides anything else with Claude SDK (and claude-code). There is no need to show this button in API key cases.
2026-03-04 12:01:28 +03:00
PaloSP
b0a3fdf95f feat: add terminal shortcuts panel for mobile (#411)
* feat: add terminal shortcuts panel for mobile users

Slide-out panel providing touch-friendly shortcut buttons (Esc, Tab,
Shift+Tab, Arrow Up/Down) and scroll-to-bottom for the terminal.
Integrates into the new modular shell architecture by exposing
terminalRef and wsRef from useShellRuntime hook and reusing the
existing sendSocketMessage utility.

Includes localization keys for en, ja, ko, and zh-CN.

* fix: replace dual touch/click handlers with unified pointer events

Prevents double-fire on touch devices by removing onTouchEnd handlers
and using a single onClick for all interactions (mouse, touch, keyboard).
onPointerDown with preventDefault handles focus steal prevention.
Also clears pending close timer before scheduling a new one to avoid
stale timeout overlap.

Addresses CodeRabbit review feedback on PR #411.

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-03-04 11:41:00 +03:00
shikihane
4ee88f0eb0 fix: preserve pending permission requests across WebSocket reconnections (#462)
* fix: preserve pending permission requests across WebSocket reconnections

- Store WebSocketWriter reference in active sessions for reconnection
- Add reconnectSessionWriter() to swap writer when client reconnects
- Add getPendingApprovalsForSession() to query pending permissions by session
- Add get-pending-permissions WebSocket message handler on server
- Add pending-permissions-response handler on frontend
- Query pending permissions on WebSocket reconnect and session change
- Reconnect SDK output writer when client resumes an active session

* fix: address CodeRabbit review feedback for websocket-permission PR

- Use consistent session matching in pending-permissions-response handler,
  checking both currentSessionId and selectedSession.id (matching the
  session-status handler pattern)
- Guard get-pending-permissions with isClaudeSDKSessionActive check to
  prevent returning permissions for inactive sessions
2026-03-03 20:31:27 +03:00
shikihane
688d73477a fix: prevent React 18 batching from losing messages during session sync (#461)
* fix: prevent React 18 batching from losing messages during session sync

- Add lastLoadedSessionIdRef to skip redundant session reloads
- Add length-based guards to sessionMessages -> chatMessages sync effect
- Track whether chat is actively processing to prevent stale overwrites
- Re-enable sessionMessages sync with proper guards that only fire when
  server data actually grew (new messages from server), not during re-renders

* fix: reset message sync refs on session switch

Reset prevSessionMessagesLengthRef and isInitialLoadRef when
selectedSession changes to ensure correct message sync behavior
when switching between sessions. Move ref declarations before
their first usage for clarity.

* fix: address CodeRabbit review — composite cache key and empty sync

- Use composite key (sessionId:projectName:provider) for session load
  deduplication instead of sessionId alone, preventing stale cache hits
  when switching projects or providers with the same session ID
- Allow zero-length sessionMessages to sync to chatMessages, so server
  clearing a session's messages is correctly reflected in the UI
2026-03-03 19:47:07 +03:00
PaloSP
198e3da89b feat: implement session rename with SQLite storage (#413)
* feat: implement session rename with SQLite storage (closes #72, fixes #358)

- Add session_names table to store custom display names per provider
- Add PUT /api/sessions/:sessionId/rename endpoint
- Replace stub updateSessionSummary with real API call
- Apply custom names across all providers (Claude, Codex, Cursor)
- Fix project rename destroying config (spread merge instead of overwrite)
- Thread provider parameter through sidebar component chain
- Add i18n error messages for rename failures (en, ja, ko, zh-CN)

* fix: address CodeRabbit review feedback for session rename

- Log migration errors instead of swallowing them silently (db.js)
- Add try/catch to applyCustomSessionNames to prevent getProjects abort
- Move applyCustomSessionNames to db.js as shared helper (DRY)
- Fix Cursor getSessionName to check session.summary for custom names
- Move edit state clearing to finally block in updateSessionSummary
- Sanitize sessionId, add 500-char summary limit, validate provider whitelist
- Remove dead applyCustomSessionNames call on empty manual project sessions

* fix: reject sessionId on mismatch instead of silent normalization

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

* fix: enable rename for all providers, add Gemini support, clean up orphans

- Enable rename UI (pencil icon) for Codex, Cursor, and Gemini sessions
- Keep delete button hidden for Cursor (no backend delete endpoint)
- Add 'gemini' to VALID_PROVIDERS and hoist to module scope
- Add sessionNamesDb.deleteName on session delete (claude, codex, gemini)
- Fix token-usage endpoint sessionId mismatch validation
- Remove redundant try/catch in sessionNamesDb methods
- Let session_names migration errors propagate to outer handler

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-03-03 18:11:26 +03:00
simosmik
4da27ae5f1 feat: announce releases on discord bot 2026-03-03 15:10:17 +00:00
41 changed files with 905 additions and 135 deletions

22
.github/workflows/discord-release.yml vendored Normal file
View 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

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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);

View File

@@ -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' });
}

View File

@@ -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()
};
}

View File

@@ -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
};
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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 ? (

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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>
);

View File

@@ -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);

View File

@@ -23,6 +23,7 @@ export type AuthStatus = {
email: string | null;
loading: boolean;
error: string | null;
method?: string;
};
export type KeyValueMap = Record<string, string>;

View File

@@ -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">

View File

@@ -149,6 +149,8 @@ export function useShellRuntime({
return {
terminalContainerRef,
terminalRef,
wsRef,
isConnected,
isInitialized,
isConnecting,

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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(() => {

View File

@@ -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') {

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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: (

View File

@@ -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>
);

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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"
}
}
}

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "ターミナルショートカット",
"sectionKeys": "キー",
"sectionNavigation": "ナビゲーション",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "上矢印",
"arrowDown": "下矢印",
"scrollDown": "下にスクロール",
"handle": {
"closePanel": "ショートカットパネルを閉じる",
"openPanel": "ショートカットパネルを開く"
}
},
"mainTabs": {
"label": "設定",
"agents": "エージェント",

View File

@@ -93,6 +93,8 @@
"enterProjectPath": "プロジェクトのパスを入力してください",
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの削除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの削除でエラーが発生しました。もう一度お試しください。",
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",

View File

@@ -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": "다음 작업 시작"
}
}
}

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "터미널 단축키",
"sectionKeys": "키",
"sectionNavigation": "탐색",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "위쪽 화살표",
"arrowDown": "아래쪽 화살표",
"scrollDown": "아래로 스크롤",
"handle": {
"closePanel": "단축키 패널 닫기",
"openPanel": "단축키 패널 열기"
}
},
"mainTabs": {
"label": "설정",
"agents": "에이전트",

View File

@@ -93,6 +93,8 @@
"enterProjectPath": "프로젝트 경로를 입력해주세요",
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 삭제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 삭제 오류. 다시 시도해주세요.",
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",

View File

@@ -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": "开始下一个任务"
}
}
}

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "终端快捷键",
"sectionKeys": "按键",
"sectionNavigation": "导航",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "上箭头",
"arrowDown": "下箭头",
"scrollDown": "滚动到底部",
"handle": {
"closePanel": "关闭快捷键面板",
"openPanel": "打开快捷键面板"
}
},
"mainTabs": {
"label": "设置",
"agents": "智能体",

View File

@@ -93,6 +93,8 @@
"enterProjectPath": "请输入项目路径",
"deleteSessionFailed": "删除会话失败,请重试。",
"deleteSessionError": "删除会话时出错,请重试。",
"renameSessionFailed": "重命名会话失败,请重试。",
"renameSessionError": "重命名会话时出错,请重试。",
"deleteProjectFailed": "删除项目失败,请重试。",
"deleteProjectError": "删除项目时出错,请重试。",
"createProjectFailed": "创建项目失败,请重试。",

View File

@@ -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',