From 33bbd93287ca7f0f121ec986a4f0a2ce1650e0bb Mon Sep 17 00:00:00 2001 From: dnviti Date: Mon, 27 Jul 2026 00:23:10 +0200 Subject: [PATCH] feat(sessions): a renamed tab stays renamed, and a reload comes back to it (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name the user chose used to live only in the page that typed it. The strip read it out of its own state, nothing stored it, and the next page load — a reload, a second window, a restart — rebuilt every tab from the name the session was created with. The one moment the labels matter, coming back to a set of long-running sessions, was exactly the moment they were gone. The chosen name is now a field of its own on the session record, stored beside the created name rather than over it, so a session nobody renamed still goes through the generated-name rules and shows its folder. A new nullable `custom_name` column carries it across a restart. `PATCH /api/sessions/:id/name` takes the rename and tells every socket the user has open, so a window that is already looking at the same sessions follows without reloading; the page that asked renames optimistically and puts the old label back if the server refuses. The same reload also used to lose the user's place. The selected tab is now remembered in the browser — sessionStorage first, so two windows keep their own tab, with localStorage behind it so a newly opened window still lands somewhere sensible — and a remembered id for a session that is gone falls back to the first tab rather than an empty screen. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 ++ src/client/app.ts | 7 +- src/client/sessions/tab-manager.ts | 152 ++++++++++++- src/client/shell/dialogs/SessionsDialog.tsx | 2 +- src/client/terminal/message-handler.ts | 7 + src/client/types.ts | 13 ++ src/server/routes/sessions.ts | 70 +++++- src/server/services/database.ts | 5 + src/server/services/session-store.ts | 17 +- src/server/types.ts | 16 ++ test/browser/checks.ts | 110 ++++++++++ test/session-rename.test.js | 223 ++++++++++++++++++++ test/session-store.test.js | 36 ++++ test/tab-manager-state.test.js | 197 ++++++++++++++++- 14 files changed, 851 insertions(+), 20 deletions(-) create mode 100644 test/session-rename.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1cbf9..d9dfc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ ## [5.2.0] - Unreleased ### Added +- **A tab you renamed stays renamed, and a reload brings you back to it.** The + name you gave a session used to live only in the page you typed it in: reload + the browser, or open the app in a second window, and every tab was back to the + name it was created with. A chosen name now belongs to the session. It comes + back after a reload, it is the same name in every window and on every device, + it reaches windows that are already open without them reloading, and it + survives the app being restarted and the session being recovered. Sessions + nobody renamed are unchanged — they still show their generated name, with the + folder name standing in for it. + + The same reload also used to lose your place, dropping you on the first tab + whichever one you had been working in. It now returns you to the tab you were + last on, remembered per window, so two windows can each sit on their own + session. If that session is gone by the time you come back, the app falls back + to the first tab rather than showing you nothing. + - **You can open what the agent handed off and watch it work.** A delegation used to be one line in the agents list: a name, a status badge, a duration. Whether it was a sub-agent reading three files or a workflow running a dozen diff --git a/src/client/app.ts b/src/client/app.ts index 2078c34..cc83b49 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -259,8 +259,11 @@ export class App { this.splitContainer.setupDropZones(); if (this.sessionTabManager.tabs.size > 0) { - const firstTabId = this.sessionTabManager.tabs.keys().next().value; - await this.sessionTabManager.switchToTab(firstTabId!); + // The tab this browser was last on, or the first one if that session is + // gone — not always the first one, which sent every reload back to the + // start of the strip. + const initialTabId = this.sessionTabManager.initialTabId(); + if (initialTabId) await this.sessionTabManager.switchToTab(initialTabId); hideOverlay(); } else { hideOverlay(); diff --git a/src/client/sessions/tab-manager.ts b/src/client/sessions/tab-manager.ts index 3d2f601..14ae5be 100644 --- a/src/client/sessions/tab-manager.ts +++ b/src/client/sessions/tab-manager.ts @@ -19,6 +19,15 @@ interface TabRecord { id: string; /** The label shown on the tab, which is not always the session name. */ displayName: string; + /** + * The label the user chose, if they chose one. + * + * Kept apart from `displayName` so a rename that the server refuses can be + * put back the way it was, and so a session the user never renamed still goes + * through the generated-name rules rather than being pinned to whatever the + * strip happened to be showing. + */ + customName?: string; /** * Which surface the session runs on, as far as this client knows. * @@ -30,6 +39,56 @@ interface TabRecord { surface: 'terminal' | 'chat'; } +/** + * Where this browser remembers the tab it was last on. + * + * In the browser and not on the server, because which tab you are looking at is + * a property of the window you are looking at it in — a shared answer would have + * a second window dragging the first one around. + * + * Written to both stores and read from sessionStorage first. sessionStorage is + * per window, which is what lets two windows sit on different sessions and each + * come back to its own after a reload. localStorage is the fallback for a window + * that has no session storage to read yet — a newly opened one, or a browser + * started fresh — which would otherwise always land on the first tab. + */ +const ACTIVE_TAB_KEY = 'cc-web-active-tab'; + +function rememberActiveTab(sessionId: string): void { + for (const store of storages()) { + try { + store.setItem(ACTIVE_TAB_KEY, sessionId); + } catch { + // Private mode, a full quota, storage switched off. Losing the memory of + // which tab was open is not a reason to fail a tab switch. + } + } +} + +function recallActiveTab(): string | null { + for (const store of storages()) { + try { + const stored = store.getItem(ACTIVE_TAB_KEY); + if (stored) return stored; + } catch { + // Same as above, and the next store still gets its turn. + } + } + return null; +} + +/** This window's memory first, then the browser-wide one. */ +function storages(): Storage[] { + const found: Storage[] = []; + try { + if (typeof sessionStorage !== 'undefined') found.push(sessionStorage); + } catch { /* blocked */ } + try { + if (typeof localStorage !== 'undefined') found.push(localStorage); + } catch { /* blocked */ } + return found; +} + export class SessionTabManager { app: App; tabs: Map; @@ -242,7 +301,7 @@ export class SessionTabManager { sessions.forEach((raw, index: number) => { const session = raw as unknown as { id: string; name: string; active: boolean; workingDir: string | null; - surface?: 'terminal' | 'chat'; + surface?: 'terminal' | 'chat'; customName?: string; }; this.addTab( session.id, @@ -250,6 +309,7 @@ export class SessionTabManager { session.active ? 'active' : 'idle', session.workingDir, false, + session.customName, ); if (session.surface === 'chat') { this.setTabSurface(session.id, 'chat'); @@ -281,21 +341,27 @@ export class SessionTabManager { status: SessionInfo['status'] = 'idle', workingDir: string | null = null, autoSwitch = true, + customName?: string, ): void { if (this.tabs.has(sessionId)) return; const isDefaultSessionName = sessionName.startsWith('Session ') && sessionName.includes(':'); const folderName = workingDir ? workingDir.split('/').pop() || '/' : null; - const displayName = !isDefaultSessionName ? sessionName : (folderName || sessionName); + const generated = !isDefaultSessionName ? sessionName : (folderName || sessionName); + // A chosen name wins outright: it is the one thing about a tab the user said + // out loud, so it is not run through the generated-name rules. + const displayName = customName || generated; - this.tabs.set(sessionId, { id: sessionId, displayName, surface: 'terminal' }); + this.tabs.set(sessionId, { id: sessionId, displayName, customName, surface: 'terminal' }); if (!this.tabOrder.includes(sessionId)) { this.tabOrder.push(sessionId); } this.activeSessions.set(sessionId, { id: sessionId, - name: sessionName, + // What a notification calls this session, which is the user's name for it + // when they have given one. + name: customName || sessionName, status, workingDir, lastAccessed: Date.now(), @@ -352,6 +418,7 @@ export class SessionTabManager { } this.activeTabId = sessionId; + rememberActiveTab(sessionId); const session = this.activeSessions.get(sessionId); if (session) { @@ -446,9 +513,67 @@ export class SessionTabManager { const name = newName.trim(); if (!record || !name) return; - record.displayName = name; + const previous = { displayName: record.displayName, customName: record.customName }; + this.applyName(sessionId, name, name); + + // The label moves now and the server is told afterwards: a rename is the + // user restating something they already know, and making them watch a round + // trip for it would be the slowest part of the interaction. If the server + // refuses — a session that has since been deleted, a name it will not take — + // the old label goes back, so the strip never keeps a name that was not + // stored. + void fetch(`/api/sessions/${sessionId}/name`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + .then(async (response) => { + if (!response.ok) { + this.applyName(sessionId, previous.displayName, previous.customName); + showNotification('That name could not be saved'); + return; + } + // What was stored, which is not always what was typed: the server caps + // a very long name, and the strip showing one thing while every other + // window shows another is the disagreement this whole change is about. + const stored = await response.json().catch(() => null); + if (stored && typeof stored.name === 'string') { + this.applyRemoteName(sessionId, stored.name); + } + }) + .catch(() => { + // Offline or mid-reconnect. The tab keeps the new name for this page — + // reverting a rename because the network blinked is the more annoying + // failure — and a reload shows what was actually stored. + }); + } + + /** + * Take a rename that happened somewhere else: another window, another device. + * + * Same store the local rename writes to, so two windows on the same sessions + * cannot end up disagreeing about what a tab is called. + */ + applyRemoteName(sessionId: string, name: string): void { + const trimmed = name.trim(); + const record = this.tabs.get(sessionId); + if (!record || !trimmed || record.displayName === trimmed) return; + this.applyName(sessionId, trimmed, trimmed); + } + + /** Write a label into the tab, the session and the strip. */ + private applyName( + sessionId: string, + displayName: string, + customName: string | undefined, + ): void { + const record = this.tabs.get(sessionId); + if (!record) return; + + record.displayName = displayName; + record.customName = customName; const session = this.activeSessions.get(sessionId); - if (session) session.name = name; + if (session) session.name = displayName; this.syncShell(); } @@ -492,6 +617,21 @@ export class SessionTabManager { if (index < tabIds.length) this.switchToTab(tabIds[index]); } + /** + * The tab a freshly loaded page should open on. + * + * The one this browser was last on, if it is still there — coming back to a + * set of long-running sessions and having to find the one you were in again + * is the whole complaint. A remembered id that names a session which has since + * been closed, or ended on another device, falls back to the first tab, which + * is what the app has always done. + */ + initialTabId(): string | null { + const remembered = recallActiveTab(); + if (remembered && this.tabs.has(remembered)) return remembered; + return this.getOrderedTabIds()[0] ?? this.tabs.keys().next().value ?? null; + } + // --------------------------------------------------------------------------- // Status updates // --------------------------------------------------------------------------- diff --git a/src/client/shell/dialogs/SessionsDialog.tsx b/src/client/shell/dialogs/SessionsDialog.tsx index 6057d4d..6fc8c19 100644 --- a/src/client/shell/dialogs/SessionsDialog.tsx +++ b/src/client/shell/dialogs/SessionsDialog.tsx @@ -75,7 +75,7 @@ function SessionCard({ whiteSpace: 'nowrap', }} > - {session.name} + {session.customName || session.name}
; webSocketConnections: Map; @@ -145,7 +148,7 @@ export function createSessionRoutes(deps: SessionRoutesDeps): Router { return { id: session.id, - name: session.name, + name: displayName(session), runtime: session.lastAgent, runtimeLabel: session.runtimeLabel, lastActivity: session.lastActivity.toISOString(), @@ -204,11 +207,66 @@ export function createSessionRoutes(deps: SessionRoutesDeps): Router { connectedClients: session.connections.size, lastActivity: session.lastActivity, surface: session.surface || 'terminal', + customName: session.customName, })); res.json({ sessions: sessionList }); }); + /** + * Rename a session. + * + * The chosen label is stored beside the created name rather than over it, so + * a session that was never renamed keeps the name — and the folder-name + * substitution — it has today. + * + * Every one of this user's sockets is told, not just the one that asked: two + * windows open on the same sessions disagreeing about what a tab is called + * until somebody reloads is the same complaint as losing the name entirely. + */ + router.patch('/api/sessions/:sessionId/name', (req: Request, res: Response): void => { + const user = requireUser(res); + if (!user) { + res.status(401).json({ error: 'authentication_required' }); + return; + } + + const session = getOwnedSession(deps.claudeSessions, req.params.sessionId as string, user); + if (!session) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + const { name } = req.body ?? {}; + if (typeof name !== 'string') { + res.status(400).json({ error: 'invalid_name', message: 'Session name must be a string' }); + return; + } + + // A name is what the user typed with the whitespace taken off, and a name + // that is nothing but whitespace is not a name. Capped because this label is + // rendered in a fixed-width strip and stored on every autosave, and neither + // has any use for a paragraph. + const trimmed = name.trim().slice(0, MAX_NAME_LENGTH); + if (!trimmed) { + res.status(400).json({ error: 'empty_name', message: 'Session name cannot be empty' }); + return; + } + + session.customName = trimmed; + void deps.saveSessionsToDisk(); + + for (const wsInfo of deps.webSocketConnections.values()) { + if (wsInfo.userId !== user.id) continue; + if (wsInfo.ws.readyState !== WebSocket.OPEN) continue; + wsInfo.ws.send( + JSON.stringify({ type: 'session_renamed', sessionId: session.id, name: trimmed }), + ); + } + + res.json({ success: true, name: trimmed }); + }); + router.post('/api/sessions/create', (req: Request, res: Response): void => { const user = requireUser(res); if (!user) { @@ -322,6 +380,7 @@ export function createSessionRoutes(deps: SessionRoutesDeps): Router { res.json({ id: session.id, name: session.name, + customName: session.customName, created: session.created, active: session.active, agent: session.agent, @@ -392,7 +451,7 @@ export function createSessionRoutes(deps: SessionRoutesDeps): Router { `attachment; filename="${exportFileName(session)}"`, ); - res.write(`# ${session.name}\n\n`); + res.write(`# ${displayName(session)}\n\n`); res.write(`- Directory: \`${session.workingDir}\`\n`); res.write(`- Created: ${session.created.toISOString()}\n`); res.write(`- Last activity: ${session.lastActivity.toISOString()}\n\n`); @@ -491,8 +550,13 @@ function toPlainText(value: string): string { .replace(/`{10,}/g, '`````````'); } +/** What to call a session in front of the user: their name for it if they gave one. */ +function displayName(session: SessionRecord): string { + return session.customName || session.name; +} + function exportFileName(session: SessionRecord): string { - const safe = session.name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); + const safe = displayName(session).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); const stamp = session.created.toISOString().slice(0, 10); return `${safe || 'session'}-${stamp}.md`; } diff --git a/src/server/services/database.ts b/src/server/services/database.ts index 1bde8e1..1453d47 100644 --- a/src/server/services/database.ts +++ b/src/server/services/database.ts @@ -389,6 +389,11 @@ export class AppDatabase { // Nullable and null-by-default: every row written before this column // existed has no override recorded, which is exactly what a null means. this.addColumnIfMissing('runtime_sessions', 'chat_model_override', 'TEXT'); + + // The label the user chose for this session. Nullable and null-by-default: + // a null is "never renamed", which is true of every row written before + // renaming outlived the page that did it. + this.addColumnIfMissing('runtime_sessions', 'custom_name', 'TEXT'); } /** diff --git a/src/server/services/session-store.ts b/src/server/services/session-store.ts index 3a1eb44..306657d 100644 --- a/src/server/services/session-store.ts +++ b/src/server/services/session-store.ts @@ -36,6 +36,7 @@ interface RuntimeSessionRow { owner_session_id: string | null; chat_bypass_permissions: number | null; chat_model_override: string | null; + custom_name: string | null; } export class SessionStore { @@ -74,7 +75,8 @@ export class SessionStore { native_chat_session_id, owner_session_id, chat_bypass_permissions, - chat_model_override + chat_model_override, + custom_name ) VALUES ( @id, @@ -97,7 +99,8 @@ export class SessionStore { @native_chat_session_id, @owner_session_id, @chat_bypass_permissions, - @chat_model_override + @chat_model_override, + @custom_name ) `); @@ -164,6 +167,10 @@ export class SessionStore { // Persisted so a restart still prefers it over the profile default the // next time a session starts for this conversation. chat_model_override: session.chatModelOverride || null, + // The label the user chose. Without this a restart brings a session back + // under the name it was created with, which is the one thing the user + // renamed it to get away from. + custom_name: session.customName || null, })); replaceAll(rows); @@ -201,7 +208,8 @@ export class SessionStore { native_chat_session_id, owner_session_id, chat_bypass_permissions, - chat_model_override + chat_model_override, + custom_name FROM runtime_sessions ORDER BY created_at ASC `) @@ -251,6 +259,9 @@ export class SessionStore { // override" rather than a call to switch to nothing is the safe // direction regardless. chatModelOverride: row.chat_model_override || undefined, + // An empty string reads as "never renamed" for the same reason: the + // write side only ever stores a trimmed non-empty name or null. + customName: row.custom_name || undefined, }); } diff --git a/src/server/types.ts b/src/server/types.ts index dde1320..b59b056 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -119,6 +119,20 @@ export interface SessionRecord { * like every row written before this existed. */ chatModelOverride?: string; + /** + * The label the user chose for this session, when they have chosen one. + * + * Deliberately not `name`: `name` is the name the session was *created* with, + * and the tab strip turns a generated one into the folder name. Keeping the + * chosen label in its own field is what lets a renamed tab be renamed + * everywhere while a never-renamed one keeps behaving exactly as before — + * including when someone renames a tab to something that happens to look like + * a generated name. + * + * Absent means "never renamed", which is what every row written before this + * existed carries. + */ + customName?: string; terminalOptions: TerminalOptions | null; stopRequested: boolean; /** Identifies the current PTY run so late callbacks from a previous run are ignored. */ @@ -191,6 +205,8 @@ export interface SessionListItem { lastActivity: Date; /** Absent means terminal; the client needs it to watch chat sessions it is not driving. */ surface?: 'terminal' | 'chat'; + /** The user's chosen label, when there is one. Absent means "never renamed". */ + customName?: string; } export interface BridgeInterface { diff --git a/test/browser/checks.ts b/test/browser/checks.ts index d3fe6a8..e289446 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -28,6 +28,7 @@ import { MoreSheet } from '../../src/client/shell/MoreSheet'; import { ChatSettingsDialog } from '../../src/client/shell/dialogs/ChatSettingsDialog'; import { SessionsDialog } from '../../src/client/shell/dialogs/SessionsDialog'; import { TabSwitcherSheet } from '../../src/client/shell/TabSwitcherSheet'; +import { TabBar } from '../../src/client/ui/relay/TabBar'; const results: string[] = []; const check = (name: string, ok: boolean, detail = ''): void => { @@ -238,6 +239,7 @@ async function run(): Promise { await checkSilentStepsLeaveNoRowButKeepTheirTrace(); await checkThePhoneLayoutIsUsable(); await checkThePhoneShellSurfacesAreUsable(); + await checkALongTabNameStaysInsideTheStrip(); const pre = document.createElement('pre'); pre.id = 'results'; @@ -2079,6 +2081,114 @@ async function checkThePhoneShellSurfacesAreUsable(): Promise { } } +/** + * A name the user chose can be any length (issue #54). + * + * Names used to live only in the page that typed them, so nothing longer than + * a session id ever survived to be looked at twice. Now a name is stored and + * comes back on every page load, on every device — including the 300-character + * one somebody pasted in — so the strip has to keep it inside its own row + * instead of stretching until the tabs beside it are unreachable. + */ +async function checkALongTabNameStaysInsideTheStrip(): Promise { + const LONG = 'a session name that somebody pasted in from a commit message and never shortened, ' + + 'complete with a path /home/dev/projects/deeply/nested/thing and a ticket reference #54'; + + const host = document.createElement('div'); + host.style.cssText = 'width:900px;position:absolute;top:0;left:0'; + document.body.appendChild(host); + + const root = createRoot(host); + root.render( + React.createElement(TabBar, { + tabs: [ + { id: 't1', title: LONG, status: 'running' }, + { id: 't2', title: 'short', status: 'idle' }, + { id: 't3', title: 'also short', status: 'idle' }, + ], + activeId: 't1', + onSelect: () => {}, + onClose: () => {}, + onNew: () => {}, + ariaLabel: 'Sessions', + } as never), + ); + await wait(200); + + const strip = host.querySelector('[role="tablist"][aria-label="Sessions"]') as HTMLElement; + const tabs = Array.from(strip.querySelectorAll('[role="tab"]')); + const stripBox = strip.getBoundingClientRect(); + + check( + 'a very long tab name does not widen its tab past the strip’s limit', + tabs[0].getBoundingClientRect().width <= 209, + `${Math.round(tabs[0].getBoundingClientRect().width)}px`, + ); + check( + 'the tabs beside it are still on screen', + tabs.length === 3 && tabs[2].getBoundingClientRect().right <= stripBox.right + 1, + `${tabs.length} tabs, last right=${Math.round(tabs[2]?.getBoundingClientRect().right ?? -1)} strip right=${Math.round(stripBox.right)}`, + ); + check( + 'the strip is still one row tall', + host.getBoundingClientRect().height <= 37, + `${Math.round(host.getBoundingClientRect().height)}px`, + ); + + root.unmount(); + host.remove(); + + // And the same name in the phone's tab switcher, where the row is the whole + // width of the screen and there is nowhere for an overflow to hide. + const frame = document.createElement('iframe'); + frame.style.cssText = 'width:390px;height:740px;position:absolute;top:0;left:0;border:0'; + document.body.appendChild(frame); + const doc = frame.contentDocument as Document; + doc.open(); + doc.write( + '' + + '' + + '' + + '', + ); + doc.close(); + await wait(150); + + const sheetRoot = createRoot(doc.body); + sheetRoot.render( + React.createElement( + PhoneContext.Provider, + { value: true }, + React.createElement(TabSwitcherSheet, { + open: true, + tabs: [ + { id: 't1', title: LONG, kind: 'chat' }, + { id: 't2', title: 'short', kind: 'terminal' }, + ], + activeId: 't1', + onSelect: () => {}, onCloseTab: () => {}, onNew: () => {}, + onAllSessions: () => {}, onClose: () => {}, + } as never), + ), + ); + await wait(300); + settle(doc); + + const spilling = Array.from(doc.body.querySelectorAll('*')).filter((node) => { + if (!isPainted(node) || scrollsSideways(node)) return false; + const box = node.getBoundingClientRect(); + return box.right > 391 || box.left < -1; + }); + check( + 'a very long tab name does not push the phone tab switcher off the side', + spilling.length === 0, + spilling.length ? spilling.slice(0, 6).map((n) => describe(n)).join(' | ') : 'nothing overflowing', + ); + + sheetRoot.unmount(); + frame.remove(); +} + /** The floating menu with its own button already pressed. */ function OpenFloatingMenu({ actions }: { actions: FloatingMenuAction[] }): React.ReactElement { const ref = React.useRef(null); diff --git a/test/session-rename.test.js b/test/session-rename.test.js new file mode 100644 index 0000000..9928f36 --- /dev/null +++ b/test/session-rename.test.js @@ -0,0 +1,223 @@ +const assert = require('assert'); +const express = require('express'); +const http = require('http'); + +const { createSessionRoutes } = require('../dist/server/routes/sessions.js'); + +// A name the user chose belongs to the session, not to the page that typed it. +// +// Which means two things this file checks: the record keeps it (so an autosave +// carries it across a restart, and every later page load reads it back from the +// listing), and every socket the same user has open is told, so a second window +// does not sit there disagreeing about what a tab is called until someone +// reloads it. + +const USER = { id: 7, githubId: '1', githubLogin: 'tester', githubName: null, avatarUrl: null, email: null }; +const OTHER = { id: 8, githubId: '2', githubLogin: 'other', githubName: null, avatarUrl: null, email: null }; + +let sessions; +let sockets; +let server; +let base; +let currentUser; +let saves; + +function record(id, over = {}) { + return { + id, + ownerUserId: 7, + name: `Session ${id}`, + created: new Date('2026-07-01T10:00:00Z'), + lastActivity: new Date('2026-07-01T10:00:00Z'), + active: false, + agent: null, + lastAgent: null, + runtimeLabel: null, + terminalOptions: null, + stopRequested: false, + workingDir: '/projects/alpha', + connections: new Set(), + outputBuffer: [], + termCols: 80, + termRows: 24, + sessionStartTime: null, + sessionUsage: {}, + maxBufferSize: 1000, + ...over, + }; +} + +/** A socket that records what it was sent. `readyState` 1 is OPEN. */ +function socket(id, userId, readyState = 1) { + const sent = []; + return { + id, + userId, + githubLogin: 'tester', + claudeSessionId: null, + chatSessionIds: new Set(), + created: new Date(), + sent, + ws: { + readyState, + send: (payload) => sent.push(JSON.parse(payload)), + }, + }; +} + +// Inside the describe, not at the top level: a top-level `before` is a ROOT +// hook and would stand a server up for every other suite in the run. +describe('renaming a session', function () { +before(async function () { + this.timeout(30000); + + sessions = new Map(); + sockets = new Map(); + saves = 0; + currentUser = USER; + + const app = express(); + app.use(express.json()); + app.use((_req, res, next) => { + res.locals.authContext = { user: currentUser, authSessionId: 'a' }; + next(); + }); + app.use( + createSessionRoutes({ + claudeSessions: sessions, + webSocketConnections: sockets, + baseFolder: '/projects', + dev: false, + validatePath: (target) => ({ valid: true, path: target }), + createSessionRecord: (params) => record(params.id, params), + getRuntimeBridge: () => null, + saveSessionsToDisk: async () => { saves++; }, + transcriptStore: { ensureTranscript: async () => {}, deleteTranscript: async () => {} }, + historyStore: { deleteHistory: async () => {} }, + getScreenSnapshot: () => [], + disposeRecorder: () => {}, + getSelectedWorkingDir: () => null, + sessionStore: { getSessionMetadata: async () => ({}) }, + }), + ); + + server = http.createServer(app); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + base = `http://127.0.0.1:${server.address().port}`; + resolve(); + }); + }); +}); + +after(function () { + if (server) server.close(); +}); + +beforeEach(function () { + sessions.clear(); + sockets.clear(); + currentUser = USER; + saves = 0; +}); + +async function rename(id, body) { + const response = await fetch(`${base}/api/sessions/${encodeURIComponent(id)}/name`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +async function list() { + const response = await fetch(`${base}/api/sessions/list`); + return (await response.json()).sessions; +} + + it('stores the chosen name beside the created one and saves', async function () { + sessions.set('s1', record('s1')); + + const result = await rename('s1', { name: ' the good one ' }); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.name, 'the good one'); + assert.strictEqual(sessions.get('s1').customName, 'the good one'); + assert.strictEqual( + sessions.get('s1').name, + 'Session s1', + 'the created name is left alone, so a session nobody renamed is unaffected', + ); + assert.strictEqual(saves, 1, 'the rename is persisted rather than waiting for the next autosave'); + }); + + it('reports the chosen name in the listing every page boots from', async function () { + sessions.set('s1', record('s1')); + sessions.set('s2', record('s2')); + + await rename('s1', { name: 'renamed' }); + + const rows = await list(); + assert.strictEqual(rows.find((row) => row.id === 's1').customName, 'renamed'); + assert.strictEqual( + rows.find((row) => row.id === 's2').customName, + undefined, + 'a session nobody renamed reports no chosen name', + ); + }); + + it('tells every one of this user\'s sockets, and nobody else\'s', async function () { + sessions.set('s1', record('s1')); + const mine = socket('w1', USER.id); + const otherWindow = socket('w2', USER.id); + const closed = socket('w3', USER.id, 3); + const stranger = socket('w4', OTHER.id); + for (const info of [mine, otherWindow, closed, stranger]) sockets.set(info.id, info); + + await rename('s1', { name: 'renamed' }); + + const expected = { type: 'session_renamed', sessionId: 's1', name: 'renamed' }; + assert.deepStrictEqual(mine.sent, [expected]); + assert.deepStrictEqual(otherWindow.sent, [expected], 'an already-open window follows without a reload'); + assert.deepStrictEqual(closed.sent, [], 'a socket that is not open is skipped'); + assert.deepStrictEqual(stranger.sent, [], 'another user hears nothing about this session'); + }); + + it('refuses a name that is not a name', async function () { + sessions.set('s1', record('s1')); + + assert.strictEqual((await rename('s1', { name: ' ' })).status, 400); + assert.strictEqual((await rename('s1', { name: '' })).status, 400); + assert.strictEqual((await rename('s1', { name: 42 })).status, 400); + assert.strictEqual((await rename('s1', {})).status, 400); + assert.strictEqual( + sessions.get('s1').customName, + undefined, + 'a refused rename leaves the previous name in place', + ); + assert.strictEqual(saves, 0); + }); + + it('caps a very long name rather than storing a paragraph', async function () { + sessions.set('s1', record('s1')); + + const result = await rename('s1', { name: 'x'.repeat(5000) }); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.name.length, 200); + assert.strictEqual(sessions.get('s1').customName.length, 200); + }); + + it('will not rename a session this user does not own', async function () { + sessions.set('s1', record('s1', { ownerUserId: OTHER.id })); + + const result = await rename('s1', { name: 'mine now' }); + + assert.strictEqual(result.status, 404); + assert.strictEqual(sessions.get('s1').customName, undefined); + }); + + it('404s for a session that no longer exists', async function () { + assert.strictEqual((await rename('ghost', { name: 'anything' })).status, 404); + }); +}); diff --git a/test/session-store.test.js b/test/session-store.test.js index 8e04608..dd9f026 100644 --- a/test/session-store.test.js +++ b/test/session-store.test.js @@ -162,6 +162,42 @@ describe('SessionStore', function() { assert.strictEqual(loaded.get('default').chatModelOverride, undefined); }); + it('remembers the name the user gave a session', async function () { + // The one moment a chosen name matters most is coming back to a set of + // long-running sessions after a restart, which is exactly the moment it + // has to survive. A session nobody renamed reads as "never renamed", not + // as an empty name. + await sessionStore.saveSessions(new Map([ + ['named', createSessionRecord({ id: 'named', ownerUserId, customName: 'the good one' })], + ['plain', createSessionRecord({ id: 'plain', ownerUserId })], + ])); + sessionStore.database.close(); + sessionStore = new SessionStore({ dataDir: tempDir }); + + const loaded = await sessionStore.loadSessions(); + assert.strictEqual(loaded.get('named').customName, 'the good one'); + assert.strictEqual(loaded.get('plain').customName, undefined); + assert.strictEqual( + loaded.get('named').name, + createSessionRecord({ id: 'named', ownerUserId }).name, + 'the created name is kept alongside the chosen one', + ); + }); + + it('adds the custom-name column to a database that predates it', async function () { + await sessionStore.saveSessions(new Map([ + ['old', createSessionRecord({ id: 'old', ownerUserId })], + ])); + sessionStore.database.raw.exec('ALTER TABLE runtime_sessions DROP COLUMN custom_name'); + sessionStore.database.close(); + + sessionStore = new SessionStore({ dataDir: tempDir }); + const loaded = await sessionStore.loadSessions(); + + assert.strictEqual(loaded.size, 1, 'the upgrade must not cost the user their sessions'); + assert.strictEqual(loaded.get('old').customName, undefined); + }); + it('adds the model-override column to a database that predates it', async function () { await sessionStore.saveSessions(new Map([ ['old', createSessionRecord({ id: 'old', ownerUserId, surface: 'chat' })], diff --git a/test/tab-manager-state.test.js b/test/tab-manager-state.test.js index 41aeef5..4c4e54b 100644 --- a/test/tab-manager-state.test.js +++ b/test/tab-manager-state.test.js @@ -21,9 +21,36 @@ const ROOT = path.join(__dirname, '..'); let mod; -const STUBBED = ['window', 'document', 'navigator', 'fetch']; +const STUBBED = ['window', 'document', 'navigator', 'fetch', 'localStorage', 'sessionStorage']; + +/** Renames and the remembered tab both go out over the network / to storage. */ +let requests; +let respondTo; +/** The browser-wide store, and this window's own. */ +let stored; +let perWindow; const originals = {}; +function installStubs() { + global.window = { innerWidth: 1280 }; + global.document = { addEventListener() {}, visibilityState: 'visible', title: 'test' }; + global.navigator = { maxTouchPoints: 0, userAgent: 'node' }; + global.fetch = async (url, init) => { + requests.push({ url, init }); + return respondTo(url, init); + }; + global.localStorage = storage(stored); + global.sessionStorage = storage(perWindow); +} + +function storage(map) { + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => { map.set(key, String(value)); }, + removeItem: (key) => { map.delete(key); }, + }; +} + /** The narrowest App the manager actually reaches for. */ function fakeApp() { const joined = []; @@ -70,10 +97,11 @@ describe('session tab state', function () { // this file first took the whole update-routes suite down with it. for (const name of STUBBED) originals[name] = global[name]; - global.window = { innerWidth: 1280 }; - global.document = { addEventListener() {}, visibilityState: 'visible', title: 'test' }; - global.navigator = { maxTouchPoints: 0, userAgent: 'node' }; - global.fetch = async () => ({ ok: true, json: async () => ({ sessions: [] }) }); + requests = []; + respondTo = () => ({ ok: true, json: async () => ({ sessions: [] }) }); + stored = new Map(); + perWindow = new Map(); + installStubs(); const contents = [ `export { SessionTabManager } from ${JSON.stringify(path.join(ROOT, 'src/client/sessions/tab-manager'))};`, @@ -104,6 +132,17 @@ describe('session tab state', function () { } }); + // Re-stubbed per test, not just once: another suite in this run deletes + // `document`, `navigator` and `localStorage` in a ROOT afterEach, which runs + // after every test in the whole process — including these. + beforeEach(function () { + requests = []; + stored.clear(); + perWindow.clear(); + respondTo = () => ({ ok: true, json: async () => ({ sessions: [] }) }); + installStubs(); + }); + it('keeps the label out of the DOM and renames in place', function () { const { m } = manager(); m.addTab('s1', 'my-session', 'idle', '/repos/thing', false); @@ -274,4 +313,152 @@ describe('session tab state', function () { 'an id for a session that no longer exists is ignored', ); }); + + // A rename that only lives in the page that typed it is the bug in #54: the + // name is gone on the next reload, and a second window never hears about it. + describe('renaming outlives the page', function () { + it('tells the server, without waiting for it to answer', function () { + const { m } = manager(); + m.addTab('s1', 'One', 'idle', '/repos/one', false); + + m.renameTab('s1', ' the good one '); + + assert.strictEqual(shellTab('s1').title, 'the good one', 'the label moves immediately'); + assert.strictEqual(requests.length, 1); + assert.strictEqual(requests[0].url, '/api/sessions/s1/name'); + assert.strictEqual(requests[0].init.method, 'PATCH'); + assert.deepStrictEqual(JSON.parse(requests[0].init.body), { name: 'the good one' }); + }); + + it('settles on the name the server actually stored', async function () { + const { m } = manager(); + m.addTab('s1', 'One', 'idle', null, false); + + // The server caps a very long name. A strip still showing the uncapped + // one would disagree with every other window the user has open. + respondTo = () => ({ ok: true, json: async () => ({ success: true, name: 'x'.repeat(200) }) }); + m.renameTab('s1', 'x'.repeat(5000)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(shellTab('s1').title.length, 200); + }); + + it('does not ask the server to store a blank name', function () { + const { m } = manager(); + m.addTab('s1', 'One', 'idle', null, false); + + m.renameTab('s1', ' '); + + assert.strictEqual(shellTab('s1').title, 'One'); + assert.strictEqual(requests.length, 0, 'a name that is only whitespace never leaves the page'); + }); + + it('puts the old label back when the server refuses', async function () { + const { m } = manager(); + m.addTab('s1', 'Session 7/23/2026, 10:26:39 AM', 'idle', '/repos/thing', false); + assert.strictEqual(shellTab('s1').title, 'thing'); + + respondTo = () => ({ ok: false, status: 404, json: async () => ({}) }); + m.renameTab('s1', 'gone'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual( + shellTab('s1').title, + 'thing', + 'a name the server would not store is not left on the strip', + ); + }); + + it('keeps the new label when the request never gets out', async function () { + const { m } = manager(); + m.addTab('s1', 'One', 'idle', null, false); + + respondTo = () => { throw new Error('offline'); }; + m.renameTab('s1', 'still mine'); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(shellTab('s1').title, 'still mine'); + }); + + it('takes a rename that happened in another window', function () { + const { m } = manager(); + m.addTab('s1', 'One', 'idle', null, false); + + m.applyRemoteName('s1', ' from next door '); + + assert.strictEqual(shellTab('s1').title, 'from next door'); + assert.strictEqual(requests.length, 0, 'hearing about a rename does not echo it back'); + + m.applyRemoteName('unknown', 'nobody'); + m.applyRemoteName('s1', ' '); + assert.strictEqual(shellTab('s1').title, 'from next door'); + }); + + it('shows the stored name for a session that was renamed before this page loaded', function () { + const { m } = manager(); + // What `/api/sessions/list` reports for a renamed session: the created + // name it still has, plus the name the user gave it. + m.addTab('s1', 'Session 7/23/2026, 10:26:39 AM', 'idle', '/repos/thing', false, 'the good one'); + + assert.strictEqual( + shellTab('s1').title, + 'the good one', + 'a chosen name is not run through the generated-name rules', + ); + + const { m: m2 } = manager(); + m2.addTab('s2', 'Session 7/23/2026, 10:26:39 AM', 'idle', '/repos/thing', false); + assert.strictEqual( + m2.tabs.get('s2').customName, + undefined, + 'a session nobody renamed carries no chosen name', + ); + }); + }); + + describe('the selected tab outlives the page', function () { + it('remembers the tab that was switched to', async function () { + const { m } = manager(); + m.addTab('a', 'a', 'idle', null, false); + m.addTab('b', 'b', 'idle', null, false); + + await m.switchToTab('b'); + + assert.strictEqual(perWindow.get('cc-web-active-tab'), 'b', 'this window remembers it'); + assert.strictEqual(stored.get('cc-web-active-tab'), 'b', 'and so does the browser, for the next new window'); + }); + + it('does not let a second window drag this one off its tab', async function () { + const { m } = manager(); + m.addTab('a', 'a', 'idle', null, false); + m.addTab('b', 'b', 'idle', null, false); + await m.switchToTab('b'); + + // Another window of the same browser: its own sessionStorage, the same + // localStorage. It settles on 'a', which must not move this window. + stored.set('cc-web-active-tab', 'a'); + + assert.strictEqual( + m.initialTabId(), + 'b', + 'this window\'s own memory wins over the browser-wide one', + ); + }); + + it('opens on the remembered tab, and on the first one when it is gone', function () { + const { m } = manager(); + m.addTab('a', 'a', 'idle', null, false); + m.addTab('b', 'b', 'idle', null, false); + + stored.set('cc-web-active-tab', 'b'); + assert.strictEqual(m.initialTabId(), 'b'); + + // The remembered session was closed elsewhere, or ended with the server. + stored.set('cc-web-active-tab', 'ghost'); + assert.strictEqual(m.initialTabId(), 'a', 'a stale id falls back, it does not blank the app'); + + stored.delete('cc-web-active-tab'); + assert.strictEqual(m.initialTabId(), 'a', 'a first visit behaves as it always did'); + }); + }); });