Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { generateAndPersistSessionName } from './session-naming.js';

const activeSessions = new Map();
const pendingToolApprovals = new Map();
Expand Down Expand Up @@ -471,6 +472,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
let sessionCreatedSent = false;
let tempImagePaths = [];
let tempDir = null;
let firstAssistantResponse = null;

const emitNotification = (event) => {
notifyUserIfEnabled({
Expand Down Expand Up @@ -658,6 +660,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
ws.send(msg);
}

// Capture first assistant response for session naming
if (!firstAssistantResponse && message.type === 'assistant' && message.message?.content) {
const c = message.message.content;
firstAssistantResponse = Array.isArray(c)
? c.find(p => p.type === 'text')?.text
: typeof c === 'string' ? c : null;
}

// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
Expand Down Expand Up @@ -690,6 +700,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
});
// Complete

// Fire-and-forget: auto-name new sessions (opt-in only — chat sessions pass this flag)
if (options.autoNameSession && !sessionId && !!command && capturedSessionId) {
generateAndPersistSessionName(
capturedSessionId, command, firstAssistantResponse,
options.broadcastSessionNameUpdated || null
).catch(err => console.error('Session naming failed:', err));
}

} catch (error) {
console.error('SDK query error:', error);

Expand Down
88 changes: 46 additions & 42 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ console.log('SERVER_PORT from env:', process.env.SERVER_PORT);

import express from 'express';
import { WebSocketServer, WebSocket } from 'ws';
import { connectedClients, broadcastProgress, broadcastSessionNameUpdated } from './utils/websocket-clients.js';
import os from 'os';
import http from 'http';
import cors from 'cors';
Expand Down Expand Up @@ -68,7 +69,7 @@ import pluginsRoutes from './routes/plugins.js';
import messagesRoutes from './routes/messages.js';
import { createNormalizedMessage } from './providers/types.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { initializeDatabase, sessionNamesDb, apiKeysDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
Expand Down Expand Up @@ -96,22 +97,8 @@ const WATCHER_IGNORED_PATTERNS = [
const WATCHER_DEBOUNCE_MS = 300;
let projectsWatchers = [];
let projectsWatcherDebounceTimer = null;
const connectedClients = new Set();
let isGetProjectsRunning = false; // Flag to prevent reentrant calls

// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress) {
const message = JSON.stringify({
type: 'loading_progress',
...progress
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}

// Setup file system watchers for Claude, Cursor, and Codex project/session folders
async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default;
Expand Down Expand Up @@ -398,12 +385,55 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
// Plugins API Routes (protected)
app.use('/api/plugins', authenticateToken, pluginsRoutes);

// Rename session endpoint (JWT or API key header) — must be registered
// before the /api/sessions router so the API-key auth path is not blocked
// by the global authenticateToken middleware.
app.put('/api/sessions/:sessionId/rename', (req, res, next) => {
// JWT-first: if Authorization header present, use JWT
const authHeader = req.headers['authorization'];
if (authHeader) {
return authenticateToken(req, res, next);
}
// Fallback: DB API key via x-api-key header only (no query string for security)
const apiKey = req.headers['x-api-key'];
if (apiKey) {
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

req.headers['x-api-key'] can be a string array in Node/Express; passing an array through to apiKeysDb.validateApiKey() will break validation (and could lead to unexpected behavior). Normalize to a single string (or reject arrays) before validating.

Suggested change
if (apiKey) {
if (Array.isArray(apiKey)) {
return res.status(400).json({ error: 'Invalid x-api-key header' });
}
if (typeof apiKey === 'string') {

Copilot uses AI. Check for mistakes.
const user = apiKeysDb.validateApiKey(apiKey);
if (user) { req.user = user; return next(); }
return res.status(401).json({ error: 'Invalid or inactive API key' });
}
return res.status(401).json({ error: 'Authentication required (Authorization or x-api-key header)' });
Comment on lines +397 to +404
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename endpoint’s x-api-key auth conflicts with the global /api validateApiKey middleware (server/middleware/auth.js), which also uses x-api-key for the optional process.env.API_KEY gate. In installs with API_KEY set, requests must send that header value to reach this handler, so there’s no way to also provide the per-user DB API key (and this route intentionally forbids the query-string fallback that /api/agent supports). Consider either (a) using a different header name for per-user keys, (b) changing the global middleware to use a different header, or (c) allowing ?apiKey= only when the global API_KEY gate is enabled so both checks can be satisfied.

Suggested change
// Fallback: DB API key via x-api-key header only (no query string for security)
const apiKey = req.headers['x-api-key'];
if (apiKey) {
const user = apiKeysDb.validateApiKey(apiKey);
if (user) { req.user = user; return next(); }
return res.status(401).json({ error: 'Invalid or inactive API key' });
}
return res.status(401).json({ error: 'Authentication required (Authorization or x-api-key header)' });
// Fallback: DB API key via x-api-key header.
// When the global API_KEY gate is enabled, allow ?apiKey= as an alternate
// transport so clients can satisfy both the global gate and per-user key auth.
const headerApiKey = req.headers['x-api-key'];
const queryApiKey = process.env.API_KEY ? req.query?.apiKey : undefined;
const apiKey = typeof headerApiKey === 'string' && headerApiKey
? headerApiKey
: (typeof queryApiKey === 'string' && queryApiKey ? queryApiKey : undefined);
if (apiKey) {
const user = apiKeysDb.validateApiKey(apiKey);
if (user) { req.user = user; return next(); }
return res.status(401).json({ error: 'Invalid or inactive API key' });
}
const authError = process.env.API_KEY
? 'Authentication required (Authorization, x-api-key header, or apiKey query parameter)'
: 'Authentication required (Authorization or x-api-key header)';
return res.status(401).json({ error: authError });

Copilot uses AI. Check for mistakes.
}, 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 });
Comment on lines +422 to +423
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Broadcast manual renames after persisting them.

Line 422 only updates session_names. Those SQLite writes do not hit the project watchers, so JWT/API-key renames never emit session_name_updated and other open clients stay stale until a refresh. The new live-update path needs to be triggered here as well.

💡 Suggested fix
         sessionNamesDb.setName(safeSessionId, provider, summary.trim());
+        broadcastSessionNameUpdated(safeSessionId, provider, summary.trim());
         res.json({ success: true });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
res.json({ success: true });
sessionNamesDb.setName(safeSessionId, provider, summary.trim());
broadcastSessionNameUpdated(safeSessionId, provider, summary.trim());
res.json({ success: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/index.js` around lines 422 - 423, After persisting the rename with
sessionNamesDb.setName(safeSessionId, provider, summary.trim()), also trigger
the live-update path so other clients get the change (emit the
"session_name_updated" event). Call the project/watchers notifier (e.g. the
existing emitter or websocket broadcast function) with the same identifiers and
the trimmed name — include safeSessionId, provider and summary.trim() — before
sending res.json({ success: true }); so JWT/API-key sessions and open clients
receive the update immediately.

} catch (error) {
console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);
res.status(500).json({ error: error.message });
}
});

// Unified session messages route (protected)
app.use('/api/sessions', authenticateToken, messagesRoutes);

// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);


// Serve public files (like api-docs.html)
app.use(express.static(path.join(__dirname, '../public')));

Expand Down Expand Up @@ -540,32 +570,6 @@ 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 {
Expand Down Expand Up @@ -1498,7 +1502,7 @@ function handleChatConnection(ws, request) {
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');

// Use Claude Agents SDK
await queryClaudeSDK(data.command, data.options, writer);
await queryClaudeSDK(data.command, { ...data.options, autoNameSession: true, broadcastSessionNameUpdated }, writer);
} else if (data.type === 'cursor-command') {
console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
console.log('📁 Project:', data.options?.cwd || 'Unknown');
Expand Down
5 changes: 4 additions & 1 deletion server/routes/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import crypto from 'crypto';
import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
import { addProjectManually } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { broadcastSessionNameUpdated } from '../utils/websocket-clients.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
Expand Down Expand Up @@ -952,7 +953,9 @@ router.post('/', validateExternalApiKey, async (req, res) => {
cwd: finalProjectPath,
sessionId: sessionId || null,
model: model,
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
permissionMode: 'bypassPermissions', // Bypass all permissions for API calls
autoNameSession: true,
broadcastSessionNameUpdated
}, writer);

} else if (provider === 'cursor') {
Expand Down
99 changes: 99 additions & 0 deletions server/session-naming.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import { sessionNamesDb } from './database/db.js';

const inFlight = new Set();

function cleanUserMessage(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/<command-name>[\s\S]*?<\/command-name>/g, '')
.replace(/<command-message>[\s\S]*?<\/command-message>/g, '')
.replace(/<command-args>[\s\S]*?<\/command-args>/g, '')
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
.replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '')
.trim();
}

function sanitizeSummary(text) {
if (!text || typeof text !== 'string') return null;
let s = text
.replace(/[\n\r]/g, ' ')
.replace(/^["'`]+|["'`]+$/g, '') // strip surrounding quotes
.replace(/^(Session name|Name|Title|Summary):\s*/i, '') // strip common prefixes
.trim();
if (!s) return null;
return s.substring(0, 60);
}

async function generateSessionName(userMessage, assistantResponse) {
const cleaned = cleanUserMessage(userMessage);
if (!cleaned) return null;

const prompt = `Given this coding assistant conversation, generate a concise session name (max 60 chars).

User: ${cleaned.substring(0, 500)}
Assistant: ${(assistantResponse || '').substring(0, 500)}

Rules:
- Return ONLY the session name, no quotes, no explanation
- Focus on the actual task, ignore XML tags and boilerplate content
- Be specific (e.g. "Fix auth token refresh bug" not "Code changes")
- Max 60 characters`;

const q = query({
prompt,
options: {
model: 'haiku',
tools: [],
maxTurns: 1,
persistSession: false,
permissionMode: 'plan',
},
});

const timeout = setTimeout(() => q.return?.(), 30000);
try {
for await (const msg of q) {
if (msg.type === 'result' && msg.result) {
return sanitizeSummary(msg.result);
}
}
} finally {
clearTimeout(timeout);
}
return null;
}

/**
* Generate a session name using Claude SDK and persist it to SQLite.
* Skips if a custom name (manual rename) already exists for this session.
* @param {string} sessionId
* @param {string} userMessage - First user message
* @param {string|null} assistantResponse - First assistant response
* @param {function|null} broadcastFn - Optional callback to broadcast projects_updated
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for broadcastFn says it broadcasts projects_updated, but the call site now passes broadcastSessionNameUpdated and the code emits a session_name_updated message. Update the comment so it matches the actual event/behavior.

Suggested change
* @param {function|null} broadcastFn - Optional callback to broadcast projects_updated
* @param {function|null} broadcastFn - Optional callback to broadcast session_name_updated

Copilot uses AI. Check for mistakes.
*/
export async function generateAndPersistSessionName(sessionId, userMessage, assistantResponse, broadcastFn) {
if (inFlight.has(sessionId)) return;
inFlight.add(sessionId);
try {
// Skip if user already manually renamed this session
const existing = sessionNamesDb.getName(sessionId, 'claude');
if (existing) return;

const name = await generateSessionName(userMessage, assistantResponse);
if (!name) return;

// Re-check: manual rename may have occurred during LLM call
if (sessionNamesDb.getName(sessionId, 'claude')) return;

sessionNamesDb.setName(sessionId, 'claude', name);
Comment on lines +86 to +89
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateAndPersistSessionName can still clobber a manual rename due to a race between the second getName() check and sessionNamesDb.setName() (which is an unconditional upsert). To make the “manual rename always wins” guarantee true, persist the auto-generated name atomically only if no name exists (e.g., INSERT ... ON CONFLICT DO NOTHING / conditional update), rather than check-then-set in JS.

Suggested change
// Re-check: manual rename may have occurred during LLM call
if (sessionNamesDb.getName(sessionId, 'claude')) return;
sessionNamesDb.setName(sessionId, 'claude', name);
// Persist atomically so a concurrent manual rename always wins.
const didPersist = sessionNamesDb.setNameIfAbsent(sessionId, 'claude', name);
if (!didPersist) return;

Copilot uses AI. Check for mistakes.
console.log(`Session ${sessionId} auto-named: "${name}"`);

// SQLite writes don't trigger file watchers, so broadcast explicitly
if (broadcastFn) {
broadcastFn(sessionId, 'claude', name);
}
} finally {
inFlight.delete(sessionId);
}
}
32 changes: 32 additions & 0 deletions server/utils/websocket-clients.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import WebSocket from 'ws';

// Shared set of connected WebSocket clients, used by both the main server
// (index.js) and the agent API routes to broadcast real-time updates.
export const connectedClients = new Set();

// Broadcast loading/progress updates to all connected clients.
export function broadcastProgress(progress) {
const message = JSON.stringify({
type: 'loading_progress',
...progress
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}

// Broadcast a lightweight session-name-updated event to all connected clients.
// Unlike the old broadcastProjectsUpdated(), this does NOT re-scan projects —
// the frontend patches its local state directly.
export function broadcastSessionNameUpdated(sessionId, provider, name) {
const msg = JSON.stringify({
type: 'session_name_updated',
sessionId, provider, name,
timestamp: new Date().toISOString()
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) client.send(msg);
});
}
50 changes: 50 additions & 0 deletions src/hooks/useProjectsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
Project,
ProjectSession,
ProjectsUpdatedMessage,
SessionNameUpdatedMessage,
SessionProvider,
} from '../types/app';

type UseProjectsStateArgs = {
Expand Down Expand Up @@ -226,6 +228,54 @@ export function useProjectsState({
return;
}

if (latestMessage.type === 'session_name_updated') {
const { sessionId: updatedId, provider, name } = latestMessage as SessionNameUpdatedMessage;

const sessionKey = (p: SessionProvider): keyof Project => {
switch (p) {
case 'cursor': return 'cursorSessions';
case 'codex': return 'codexSessions';
case 'gemini': return 'geminiSessions';
default: return 'sessions';
}
};

setProjects(prev =>
prev.map(project => {
const key = sessionKey(provider);
const sessions = project[key] as ProjectSession[] | undefined;
if (!sessions) return project;

const idx = sessions.findIndex(s => s.id === updatedId);
if (idx === -1) return project;

const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
return { ...project, [key]: updated };
})
);

// Also update selectedProject / selectedSession if they match
if (selectedProject) {
const key = sessionKey(provider);
const sessions = selectedProject[key] as ProjectSession[] | undefined;
if (sessions) {
const idx = sessions.findIndex(s => s.id === updatedId);
if (idx !== -1) {
const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
setSelectedProject({ ...selectedProject, [key]: updated });

if (selectedSession?.id === updatedId) {
setSelectedSession({ ...selectedSession, summary: name });
}
}
}
}

return;
Comment on lines +231 to +276
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard session_name_updated against no-op state writes.

Line 243 always produces a new projects array, even when no session matches or the summary is already current. Since this effect depends on projects/selectedProject/selectedSession, the same socket message will retrigger the effect and can spin the UI into an update loop after the first rename event.

💡 Suggested fix
     if (latestMessage.type === 'session_name_updated') {
       const { sessionId: updatedId, provider, name } = latestMessage as SessionNameUpdatedMessage;

       const sessionKey = (p: SessionProvider): keyof Project => {
         switch (p) {
           case 'cursor': return 'cursorSessions';
           case 'codex': return 'codexSessions';
           case 'gemini': return 'geminiSessions';
           default: return 'sessions';
         }
       };

-      setProjects(prev =>
-        prev.map(project => {
-          const key = sessionKey(provider);
-          const sessions = project[key] as ProjectSession[] | undefined;
-          if (!sessions) return project;
-
-          const idx = sessions.findIndex(s => s.id === updatedId);
-          if (idx === -1) return project;
-
-          const updated = [...sessions];
-          updated[idx] = { ...updated[idx], summary: name };
-          return { ...project, [key]: updated };
-        })
-      );
+      const key = sessionKey(provider);
+      setProjects(prev => {
+        let changed = false;
+        const next = prev.map(project => {
+          const sessions = project[key] as ProjectSession[] | undefined;
+          if (!sessions) return project;
+
+          const idx = sessions.findIndex(s => s.id === updatedId);
+          if (idx === -1 || sessions[idx]?.summary === name) return project;
+
+          changed = true;
+          const updated = [...sessions];
+          updated[idx] = { ...updated[idx], summary: name };
+          return { ...project, [key]: updated };
+        });
+
+        return changed ? next : prev;
+      });

-      // Also update selectedProject / selectedSession if they match
-      if (selectedProject) {
-        const key = sessionKey(provider);
-        const sessions = selectedProject[key] as ProjectSession[] | undefined;
-        if (sessions) {
-          const idx = sessions.findIndex(s => s.id === updatedId);
-          if (idx !== -1) {
-            const updated = [...sessions];
-            updated[idx] = { ...updated[idx], summary: name };
-            setSelectedProject({ ...selectedProject, [key]: updated });
-
-            if (selectedSession?.id === updatedId) {
-              setSelectedSession({ ...selectedSession, summary: name });
-            }
-          }
-        }
-      }
+      setSelectedProject(prev => {
+        if (!prev) return prev;
+        const sessions = prev[key] as ProjectSession[] | undefined;
+        if (!sessions) return prev;
+
+        const idx = sessions.findIndex(s => s.id === updatedId);
+        if (idx === -1 || sessions[idx]?.summary === name) return prev;
+
+        const updated = [...sessions];
+        updated[idx] = { ...updated[idx], summary: name };
+        return { ...prev, [key]: updated };
+      });
+
+      setSelectedSession(prev =>
+        prev?.id === updatedId && prev.summary !== name
+          ? { ...prev, summary: name }
+          : prev
+      );

       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (latestMessage.type === 'session_name_updated') {
const { sessionId: updatedId, provider, name } = latestMessage as SessionNameUpdatedMessage;
const sessionKey = (p: SessionProvider): keyof Project => {
switch (p) {
case 'cursor': return 'cursorSessions';
case 'codex': return 'codexSessions';
case 'gemini': return 'geminiSessions';
default: return 'sessions';
}
};
setProjects(prev =>
prev.map(project => {
const key = sessionKey(provider);
const sessions = project[key] as ProjectSession[] | undefined;
if (!sessions) return project;
const idx = sessions.findIndex(s => s.id === updatedId);
if (idx === -1) return project;
const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
return { ...project, [key]: updated };
})
);
// Also update selectedProject / selectedSession if they match
if (selectedProject) {
const key = sessionKey(provider);
const sessions = selectedProject[key] as ProjectSession[] | undefined;
if (sessions) {
const idx = sessions.findIndex(s => s.id === updatedId);
if (idx !== -1) {
const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
setSelectedProject({ ...selectedProject, [key]: updated });
if (selectedSession?.id === updatedId) {
setSelectedSession({ ...selectedSession, summary: name });
}
}
}
}
return;
if (latestMessage.type === 'session_name_updated') {
const { sessionId: updatedId, provider, name } = latestMessage as SessionNameUpdatedMessage;
const sessionKey = (p: SessionProvider): keyof Project => {
switch (p) {
case 'cursor': return 'cursorSessions';
case 'codex': return 'codexSessions';
case 'gemini': return 'geminiSessions';
default: return 'sessions';
}
};
const key = sessionKey(provider);
setProjects(prev => {
let changed = false;
const next = prev.map(project => {
const sessions = project[key] as ProjectSession[] | undefined;
if (!sessions) return project;
const idx = sessions.findIndex(s => s.id === updatedId);
if (idx === -1 || sessions[idx]?.summary === name) return project;
changed = true;
const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
return { ...project, [key]: updated };
});
return changed ? next : prev;
});
setSelectedProject(prev => {
if (!prev) return prev;
const sessions = prev[key] as ProjectSession[] | undefined;
if (!sessions) return prev;
const idx = sessions.findIndex(s => s.id === updatedId);
if (idx === -1 || sessions[idx]?.summary === name) return prev;
const updated = [...sessions];
updated[idx] = { ...updated[idx], summary: name };
return { ...prev, [key]: updated };
});
setSelectedSession(prev =>
prev?.id === updatedId && prev.summary !== name
? { ...prev, summary: name }
: prev
);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useProjectsState.ts` around lines 231 - 276, The
session_name_updated handler always calls setProjects and may call
setSelectedProject/setSelectedSession even when nothing changed, causing
re-renders/loops; update it to compute a new projects array by mapping projects
using the sessionKey helper, track whether any project's sessions were actually
mutated (or the summary changed), and only call setProjects when at least one
project was changed; apply the same no-op guard for selectedProject and
selectedSession (compute updated session arrays, compare/track changes and only
call setSelectedProject/setSelectedSession when something truly changed). Ensure
you reference latestMessage as SessionNameUpdatedMessage, sessionKey,
setProjects, setSelectedProject, setSelectedSession, selectedProject and
selectedSession in the implementation.

}

if (latestMessage.type !== 'projects_updated') {
return;
}
Expand Down
Loading
Loading