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