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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
152 changes: 146 additions & 6 deletions src/client/sessions/tab-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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<string, TabRecord>;
Expand Down Expand Up @@ -242,14 +301,15 @@ 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,
session.name,
session.active ? 'active' : 'idle',
session.workingDir,
false,
session.customName,
);
if (session.surface === 'chat') {
this.setTabSurface(session.id, 'chat');
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -352,6 +418,7 @@ export class SessionTabManager {
}

this.activeTabId = sessionId;
rememberActiveTab(sessionId);

const session = this.activeSessions.get(sessionId);
if (session) {
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/client/shell/dialogs/SessionsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function SessionCard({
whiteSpace: 'nowrap',
}}
>
{session.name}
{session.customName || session.name}
</div>
<div
style={{
Expand Down
7 changes: 7 additions & 0 deletions src/client/terminal/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ export class MessageHandler {
this.onSessionDeleted(message);
break;

// Someone renamed this session — in another window, on another device, or
// in this one, since the server tells every socket rather than assuming
// the asker already knows.
case 'session_renamed':
this.app.sessionTabManager?.applyRemoteName(message.sessionId, message.name);
break;

// The session we tried to reattach to is gone (e.g. after a server
// restart): drop the stale tab rather than leaving a dead terminal.
case 'session_gone':
Expand Down
13 changes: 13 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export interface SessionListItem {
created: string;
/** Absent means terminal, so a server that predates chat mode still reads. */
surface?: 'terminal' | 'chat';
/** The user's chosen label, when there is one. Absent means "never renamed". */
customName?: string;
}

export interface FolderData {
Expand Down Expand Up @@ -278,6 +280,16 @@ export interface WsSessionDeletedMessage {
message: string;
}

/**
* Sent to every one of the user's sockets when a session is renamed, including
* the one that asked, so a second window follows the new label without a reload.
*/
export interface WsSessionRenamedMessage {
type: 'session_renamed';
sessionId: string;
name: string;
}

/** Sent when a reattach targets a session the server no longer has. */
export interface WsSessionGoneMessage {
type: 'session_gone';
Expand Down Expand Up @@ -348,6 +360,7 @@ export type WsMessage =
| WsErrorMessage
| WsInfoMessage
| WsSessionDeletedMessage
| WsSessionRenamedMessage
| WsSessionGoneMessage
| WsPongMessage
| WsHistoryChunkMessage
Expand Down
Loading
Loading