feat: auto-name new sessions and support API-key renames#643
feat: auto-name new sessions and support API-key renames#643walksoda wants to merge 1 commit intositeboon:mainfrom
Conversation
Generate a concise title for new Claude sessions from the first user/assistant exchange so session lists stay scannable without manual renames. Auto-naming is opt-in, preserves manual renames, and runs against a cheap model fire-and-forget. Also extends PUT /api/sessions/:sessionId/rename to accept either JWT or x-api-key authentication.
📝 WalkthroughWalkthroughThe changes implement automatic session naming for Claude-based conversations. When a conversation completes successfully, the first assistant response is captured and used to generate a human-readable session name via LLM. This name is persisted to the database and broadcast to connected WebSocket clients. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Client as Client
participant Server as Server
participant Claude as Claude SDK
participant SessionDB as SessionDB
participant Broadcast as WebSocket Broadcast
participant Clients as Connected Clients
User->>Client: Send chat message
Client->>Server: POST /api/chat
Server->>Claude: queryClaudeSDK (with autoNameSession: true)
Claude->>Claude: Stream response
Claude-->>Server: First assistant response captured
Claude-->>Server: Streaming completes
Server->>SessionDB: Check for existing session name
alt No existing name
Server->>Claude: generateSessionName(firstResponse)
Claude->>Claude: Call Haiku model (30s timeout)
Claude-->>Server: Generated name
Server->>SessionDB: Persist auto-generated name
end
Server->>Broadcast: broadcastSessionNameUpdated(sessionId, provider, name)
Broadcast->>Clients: Send session_name_updated message
Clients->>Client: Receive WebSocket message
Client->>Client: Update selectedProject.summary
Client->>User: Display updated session name
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in “auto-name session” flow that generates a concise session title from the first user/assistant exchange, persists it to the existing session_names table, and updates the UI via a lightweight session_name_updated WebSocket event (avoiding a full projects rescan). It also extends the session rename endpoint to support API-key authentication in addition to JWT.
Changes:
- Add
session_name_updatedWebSocket message type and patch sidebar state in-place on receipt. - Introduce
server/session-naming.jsto generate + persist a short session name (Haiku, timeout, manual-rename-safe). - Refactor WebSocket client broadcasting into
server/utils/websocket-clients.jsand enable auto-naming from both chat WS and/api/agent.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/app.ts | Adds SessionNameUpdatedMessage and includes it in the socket message union. |
| src/hooks/useProjectsState.ts | Handles session_name_updated to update project/session summaries locally without refetching projects. |
| server/utils/websocket-clients.js | New shared connected-client set + broadcast helpers (progress + session name updates). |
| server/session-naming.js | New auto-naming generator + SQLite persistence with in-flight + manual-rename guardrails. |
| server/routes/agent.js | Opts agent-launched Claude sessions into auto-naming and provides broadcast callback. |
| server/index.js | Uses shared WS broadcast utilities; moves/updates rename endpoint to allow JWT-or-API-key auth; opts chat WS sessions into auto-naming. |
| server/claude-sdk.js | Captures first assistant text and fires background auto-name generation for new sessions when opted-in. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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)' }); |
There was a problem hiding this comment.
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.
| // 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 }); |
| } | ||
| // Fallback: DB API key via x-api-key header only (no query string for security) | ||
| const apiKey = req.headers['x-api-key']; | ||
| if (apiKey) { |
There was a problem hiding this comment.
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.
| if (apiKey) { | |
| if (Array.isArray(apiKey)) { | |
| return res.status(400).json({ error: 'Invalid x-api-key header' }); | |
| } | |
| if (typeof apiKey === 'string') { |
| * @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 |
There was a problem hiding this comment.
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.
| * @param {function|null} broadcastFn - Optional callback to broadcast projects_updated | |
| * @param {function|null} broadcastFn - Optional callback to broadcast session_name_updated |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/index.js`:
- Around line 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.
In `@src/hooks/useProjectsState.ts`:
- Around line 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: fc0fe6a2-5c12-4c36-a762-c6f441c87e4a
📒 Files selected for processing (7)
server/claude-sdk.jsserver/index.jsserver/routes/agent.jsserver/session-naming.jsserver/utils/websocket-clients.jssrc/hooks/useProjectsState.tssrc/types/app.ts
| sessionNamesDb.setName(safeSessionId, provider, summary.trim()); | ||
| res.json({ success: true }); |
There was a problem hiding this comment.
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.
| 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.
| 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; |
There was a problem hiding this comment.
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.
| 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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Re-check: manual rename may have occurred during LLM call | ||
| if (sessionNamesDb.getName(sessionId, 'claude')) return; | ||
|
|
||
| sessionNamesDb.setName(sessionId, 'claude', name); |
There was a problem hiding this comment.
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.
| // 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; |
Summary
Today, newly created Claude sessions are labelled by a truncated slice of the first user message. Once the sidebar fills up with dozens of sessions that all start with "help me fix...", it gets hard to tell them apart, and users end up either hand-renaming every session they want to revisit or scrolling through low-signal titles.
This PR auto-generates a short, descriptive title for each new session from the first user/assistant exchange, so session lists stay scannable without manual work. Manual rename continues to work exactly as before and always wins.
Example of a session that used to look like:
now shows up as:
As a small related change, the rename endpoint now also accepts
x-api-keyauth so API-driven clients (scripts, the agent API) can apply their own titles through the same endpoint.Why this is useful
/api/agent/launchGuardrails
autoNameSession: true. The chat WebSocket path and/api/agent/launchopt in;git.jsand other non-interactivequeryClaudeSDKcallers are untouched.haikumodel per new session, based only on the first exchange, with a 30s timeout. Failures are logged and swallowed — they never affect the chat itself.sessionNamesDb.getName()both before and after the LLM call, so a user rename during the in-flight window is never clobbered.getProjects()rescan on every auto-naming event, the server emits a smallsession_name_updatedWebSocket message and the frontend patches its local state in place.Implementation notes
server/session-naming.jsgenerates and persists titles to the existingsession_namesSQLite table. No schema change.connectedClients/broadcastProgress/broadcastSessionNameUpdatedintoserver/utils/websocket-clients.jsso both the chat WebSocket handler and/api/agent/launchcan share them.PUT /api/sessions/:sessionId/renameis registered before the global/api/sessionsJWT-only mount so its dual JWT-or-x-api-keyhandler runs first. Header-only (no query-string API key).Known limitation
Authorization for the rename endpoint is unchanged from the current upstream behaviour: any authenticated caller who knows a session ID can rename that session. Adding per-user ownership checks would require storing ownership metadata on
session_names(schema migration), which felt better scoped as a follow-up PR.Test plan
session_name_updatedWebSocket event — no full project rescanqueryClaudeSDKcallers (e.g.git.js) do not trigger auto-namingPUT /api/sessions/:id/renamewith JWTAuthorizationheader → 200PUT /api/sessions/:id/renamewithx-api-keyheader → 200npx vite buildsucceedsSummary by CodeRabbit
New Features
Improvements