diff --git a/packages/happy-app/sources/components/ActiveSessionsGroup.tsx b/packages/happy-app/sources/components/ActiveSessionsGroup.tsx
index d567b9fb9..5ca28048a 100644
--- a/packages/happy-app/sources/components/ActiveSessionsGroup.tsx
+++ b/packages/happy-app/sources/components/ActiveSessionsGroup.tsx
@@ -5,7 +5,7 @@ import { Text } from '@/components/StyledText';
import { useRouter } from 'expo-router';
import { Session, Machine } from '@/sync/storageTypes';
import { Ionicons } from '@expo/vector-icons';
-import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
+import { getSessionName, getSessionDefaultName, getSessionCustomName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { Avatar } from './Avatar';
import { Typography } from '@/constants/Typography';
import { StatusDot } from './StatusDot';
@@ -178,12 +178,19 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
...Typography.default(),
},
swipeAction: {
- width: 112,
+ width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.status.error,
},
+ swipeActionRename: {
+ width: 80,
+ height: '100%',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: theme.colors.textSecondary,
+ },
swipeActionText: {
marginTop: 4,
fontSize: 12,
@@ -367,6 +374,27 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
);
}, [performArchive]);
+ const defaultName = getSessionDefaultName(session);
+ const customName = getSessionCustomName(session);
+
+ const handleRename = React.useCallback(async () => {
+ const newName = await Modal.prompt(
+ t('sessionInfo.renameSession'),
+ t('sessionInfo.renameSessionPrompt'),
+ {
+ placeholder: defaultName,
+ defaultValue: customName || '',
+ confirmText: t('common.rename'),
+ cancelText: t('common.cancel'),
+ maxLength: 100
+ }
+ );
+
+ if (newName !== null) {
+ storage.getState().updateSessionCustomName(session.id, newName || null);
+ }
+ }, [session.id, customName, defaultName]);
+
const avatarId = React.useMemo(() => {
return getSessionAvatarId(session);
}, [session]);
@@ -470,16 +498,30 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
}
const renderRightActions = () => (
-
-
-
- {t('sessionInfo.archiveSession')}
-
-
+
+ {
+ swipeableRef.current?.close();
+ handleRename();
+ }}
+ >
+
+
+ {t('sessionInfo.renameSession')}
+
+
+
+
+
+ {t('sessionInfo.archiveSession')}
+
+
+
);
return (
diff --git a/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx b/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx
index 6e606a145..090d5b89f 100644
--- a/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx
+++ b/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx
@@ -5,7 +5,7 @@ import { Text } from '@/components/StyledText';
import { router, useRouter } from 'expo-router';
import { Session, Machine } from '@/sync/storageTypes';
import { Ionicons } from '@expo/vector-icons';
-import { getSessionName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
+import { getSessionName, getSessionDefaultName, getSessionCustomName, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome } from '@/utils/sessionUtils';
import { Avatar } from './Avatar';
import { Typography } from '@/constants/Typography';
import { StatusDot } from './StatusDot';
@@ -134,12 +134,19 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
...Typography.default('regular'),
},
swipeAction: {
- width: 112,
+ width: 80,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.status.error,
},
+ swipeActionRename: {
+ width: 80,
+ height: '100%',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: theme.colors.textSecondary,
+ },
swipeActionText: {
marginTop: 4,
fontSize: 12,
@@ -322,6 +329,27 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi
);
}, [performArchive]);
+ const defaultName = getSessionDefaultName(session);
+ const customName = getSessionCustomName(session);
+
+ const handleRename = React.useCallback(async () => {
+ const newName = await Modal.prompt(
+ t('sessionInfo.renameSession'),
+ t('sessionInfo.renameSessionPrompt'),
+ {
+ placeholder: defaultName,
+ defaultValue: customName || '',
+ confirmText: t('common.rename'),
+ cancelText: t('common.cancel'),
+ maxLength: 100
+ }
+ );
+
+ if (newName !== null) {
+ storage.getState().updateSessionCustomName(session.id, newName || null);
+ }
+ }, [session.id, customName, defaultName]);
+
const itemContent = (
(
-
-
-
- {t('sessionInfo.archiveSession')}
-
-
+
+ {
+ swipeableRef.current?.close();
+ handleRename();
+ }}
+ >
+
+
+ {t('sessionInfo.renameSession')}
+
+
+
+
+
+ {t('sessionInfo.archiveSession')}
+
+
+
);
return (
diff --git a/packages/happy-app/sources/components/SessionsList.tsx b/packages/happy-app/sources/components/SessionsList.tsx
index a3999ed91..f84d7a2a9 100644
--- a/packages/happy-app/sources/components/SessionsList.tsx
+++ b/packages/happy-app/sources/components/SessionsList.tsx
@@ -29,7 +29,6 @@ import { useHappyAction } from '@/hooks/useHappyAction';
import { sessionDelete } from '@/sync/ops';
import { HappyError } from '@/utils/errors';
import { Modal } from '@/modal';
-
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
diff --git a/packages/happy-app/sources/modal/ModalManager.ts b/packages/happy-app/sources/modal/ModalManager.ts
index 1e0cf0aaf..8a3fd5f68 100644
--- a/packages/happy-app/sources/modal/ModalManager.ts
+++ b/packages/happy-app/sources/modal/ModalManager.ts
@@ -150,10 +150,11 @@ class ModalManagerClass implements IModal {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
+ maxLength?: number;
}
): Promise {
- if (Platform.OS === 'ios' && !options?.inputType) {
- // Use native Alert.prompt on iOS (only supports basic text input)
+ if (Platform.OS === 'ios' && !options?.inputType && !options?.maxLength) {
+ // Use native Alert.prompt on iOS (only supports basic text input, no maxLength)
return new Promise((resolve) => {
// @ts-ignore - Alert.prompt is iOS only
Alert.prompt(
@@ -190,7 +191,8 @@ class ModalManagerClass implements IModal {
defaultValue: options?.defaultValue,
cancelText: options?.cancelText,
confirmText: options?.confirmText,
- inputType: options?.inputType
+ inputType: options?.inputType,
+ maxLength: options?.maxLength
} as Omit);
return new Promise((resolve) => {
diff --git a/packages/happy-app/sources/modal/components/WebPromptModal.tsx b/packages/happy-app/sources/modal/components/WebPromptModal.tsx
index 737aac2a9..b0fc3ebb5 100644
--- a/packages/happy-app/sources/modal/components/WebPromptModal.tsx
+++ b/packages/happy-app/sources/modal/components/WebPromptModal.tsx
@@ -144,6 +144,7 @@ export function WebPromptModal({ config, onClose, onConfirm }: WebPromptModalPro
autoFocus={Platform.OS === 'web'}
onSubmitEditing={handleConfirm}
returnKeyType="done"
+ maxLength={config.maxLength}
/>
diff --git a/packages/happy-app/sources/modal/types.ts b/packages/happy-app/sources/modal/types.ts
index c9cfdc640..010546e98 100644
--- a/packages/happy-app/sources/modal/types.ts
+++ b/packages/happy-app/sources/modal/types.ts
@@ -38,6 +38,7 @@ export interface PromptModalConfig extends BaseModalConfig {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
+ maxLength?: number;
}
export interface CustomModalConfig extends BaseModalConfig {
@@ -72,6 +73,7 @@ export interface IModal {
cancelText?: string;
confirmText?: string;
inputType?: 'default' | 'secure-text' | 'email-address' | 'numeric';
+ maxLength?: number;
}): Promise;
show(config: Omit): string;
hide(id: string): void;
diff --git a/packages/happy-app/sources/sync/persistence.test.ts b/packages/happy-app/sources/sync/persistence.test.ts
new file mode 100644
index 000000000..b74502d02
--- /dev/null
+++ b/packages/happy-app/sources/sync/persistence.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const mockStorage = new Map();
+
+vi.mock('react-native-mmkv', () => ({
+ MMKV: vi.fn().mockImplementation(() => ({
+ getString: (key: string) => mockStorage.get(key) ?? undefined,
+ set: (key: string, value: string) => mockStorage.set(key, value),
+ delete: (key: string) => mockStorage.delete(key),
+ clearAll: () => mockStorage.clear(),
+ }))
+}));
+
+import { loadSessionCustomNames, saveSessionCustomNames } from './persistence';
+
+describe('session custom names persistence', () => {
+ beforeEach(() => {
+ mockStorage.clear();
+ });
+
+ describe('loadSessionCustomNames', () => {
+ describe('empty state', () => {
+ it('should return empty object when no data is stored', () => {
+ expect(loadSessionCustomNames()).toEqual({});
+ });
+
+ it('should return empty object when key does not exist in storage', () => {
+ mockStorage.set('some-other-key', 'value');
+ expect(loadSessionCustomNames()).toEqual({});
+ });
+ });
+
+ describe('valid data', () => {
+ it('should return parsed object when valid JSON is stored', () => {
+ const names = { 'session-1': 'My Session', 'session-2': 'Another Session' };
+ mockStorage.set('session-custom-names', JSON.stringify(names));
+ expect(loadSessionCustomNames()).toEqual(names);
+ });
+
+ it('should handle a single session name', () => {
+ const names = { 'abc-123': 'Frontend' };
+ mockStorage.set('session-custom-names', JSON.stringify(names));
+ expect(loadSessionCustomNames()).toEqual(names);
+ });
+
+ it('should handle many session names', () => {
+ const names: Record = {};
+ for (let i = 0; i < 50; i++) {
+ names[`session-${i}`] = `Name ${i}`;
+ }
+ mockStorage.set('session-custom-names', JSON.stringify(names));
+ expect(loadSessionCustomNames()).toEqual(names);
+ });
+
+ it('should handle session names with special characters', () => {
+ const names = {
+ 'session-1': 'My "Quoted" Name',
+ 'session-2': 'Name with émojis 🚀',
+ 'session-3': 'Name/with/slashes'
+ };
+ mockStorage.set('session-custom-names', JSON.stringify(names));
+ expect(loadSessionCustomNames()).toEqual(names);
+ });
+ });
+
+ describe('error handling', () => {
+ it('should return empty object when invalid JSON is stored', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockStorage.set('session-custom-names', 'not valid json{{{');
+ expect(loadSessionCustomNames()).toEqual({});
+ spy.mockRestore();
+ });
+
+ it('should return empty object when stored value is empty string', () => {
+ mockStorage.set('session-custom-names', '');
+ expect(loadSessionCustomNames()).toEqual({});
+ });
+ });
+ });
+
+ describe('saveSessionCustomNames', () => {
+ describe('basic functionality', () => {
+ it('should save serialized JSON to MMKV', () => {
+ const names = { 'session-1': 'Custom Name' };
+ saveSessionCustomNames(names);
+ expect(mockStorage.get('session-custom-names')).toBe(JSON.stringify(names));
+ });
+
+ it('should save empty object', () => {
+ saveSessionCustomNames({});
+ expect(mockStorage.get('session-custom-names')).toBe('{}');
+ });
+
+ it('should handle names with unicode characters', () => {
+ const names = { 'session-1': '日本語セッション' };
+ saveSessionCustomNames(names);
+ expect(JSON.parse(mockStorage.get('session-custom-names')!)).toEqual(names);
+ });
+ });
+
+ describe('overwrite behavior', () => {
+ it('should overwrite previous data completely', () => {
+ saveSessionCustomNames({ 'session-1': 'First' });
+ saveSessionCustomNames({ 'session-2': 'Second' });
+ expect(JSON.parse(mockStorage.get('session-custom-names')!)).toEqual({
+ 'session-2': 'Second'
+ });
+ });
+
+ it('should not merge with previous data', () => {
+ saveSessionCustomNames({ 'session-1': 'Name A', 'session-2': 'Name B' });
+ saveSessionCustomNames({ 'session-1': 'Updated A' });
+ const result = JSON.parse(mockStorage.get('session-custom-names')!);
+ expect(result).toEqual({ 'session-1': 'Updated A' });
+ expect(result['session-2']).toBeUndefined();
+ });
+ });
+
+ describe('round-trip', () => {
+ it('should save and load data consistently', () => {
+ const names = {
+ 'session-abc': 'Frontend Server',
+ 'session-def': 'Backend API',
+ 'session-ghi': 'Database Migration'
+ };
+ saveSessionCustomNames(names);
+ expect(loadSessionCustomNames()).toEqual(names);
+ });
+
+ it('should handle save-load-save cycle', () => {
+ saveSessionCustomNames({ 'session-1': 'First' });
+ const loaded = loadSessionCustomNames();
+ loaded['session-2'] = 'Second';
+ saveSessionCustomNames(loaded);
+ expect(loadSessionCustomNames()).toEqual({
+ 'session-1': 'First',
+ 'session-2': 'Second'
+ });
+ });
+ });
+ });
+});
diff --git a/packages/happy-app/sources/sync/persistence.ts b/packages/happy-app/sources/sync/persistence.ts
index 100c9ee3a..cd9a6b1c6 100644
--- a/packages/happy-app/sources/sync/persistence.ts
+++ b/packages/happy-app/sources/sync/persistence.ts
@@ -188,6 +188,23 @@ export function saveSessionPermissionModes(modes: Record) {
mmkv.set('session-permission-modes', JSON.stringify(modes));
}
+export function loadSessionCustomNames(): Record {
+ const names = mmkv.getString('session-custom-names');
+ if (names) {
+ try {
+ return JSON.parse(names);
+ } catch (e) {
+ console.error('Failed to parse session custom names', e);
+ return {};
+ }
+ }
+ return {};
+}
+
+export function saveSessionCustomNames(names: Record) {
+ mmkv.set('session-custom-names', JSON.stringify(names));
+}
+
export function loadProfile(): Profile {
const profile = mmkv.getString('profile');
if (profile) {
diff --git a/packages/happy-app/sources/sync/storage.ts b/packages/happy-app/sources/sync/storage.ts
index 728f679ee..9d0de371c 100644
--- a/packages/happy-app/sources/sync/storage.ts
+++ b/packages/happy-app/sources/sync/storage.ts
@@ -10,7 +10,7 @@ import { LocalSettings, applyLocalSettings } from "./localSettings";
import { Purchases, customerInfoToPurchases } from "./purchases";
import { Profile } from "./profile";
import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes";
-import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence";
+import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionCustomNames, saveSessionCustomNames } from "./persistence";
import type { PermissionModeKey } from '@/components/PermissionModeSelector';
import type { CustomerInfo } from './revenueCat/types';
import React from "react";
@@ -118,6 +118,7 @@ interface StorageState {
getActiveSessions: () => Session[];
updateSessionDraft: (sessionId: string, draft: string | null) => void;
updateSessionPermissionMode: (sessionId: string, mode: string) => void;
+ updateSessionCustomName: (sessionId: string, customName: string | null) => void;
updateSessionModelMode: (sessionId: string, mode: string) => void;
// Artifact methods
applyArtifacts: (artifacts: DecryptedArtifact[]) => void;
@@ -251,6 +252,7 @@ export const storage = create()((set, get) => {
let profile = loadProfile();
let sessionDrafts = loadSessionDrafts();
let sessionPermissionModes = loadSessionPermissionModes();
+ let sessionCustomNames = loadSessionCustomNames();
return {
settings,
settingsVersion: version,
@@ -302,6 +304,7 @@ export const storage = create()((set, get) => {
// Load drafts and permission modes if sessions are empty (initial load)
const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {};
const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {};
+ const savedCustomNames = Object.keys(state.sessions).length === 0 ? sessionCustomNames : {};
// Merge new sessions with existing ones
const mergedSessions: Record = { ...state.sessions };
@@ -316,6 +319,8 @@ export const storage = create()((set, get) => {
const savedDraft = savedDrafts[session.id];
const existingPermissionMode = state.sessions[session.id]?.permissionMode;
const savedPermissionMode = savedPermissionModes[session.id];
+ const existingCustomName = state.sessions[session.id]?.customName;
+ const savedCustomName = savedCustomNames[session.id];
const defaultPermissionMode: PermissionModeKey = isSandboxEnabled(session.metadata) ? 'bypassPermissions' : 'default';
const resolvedPermissionMode: PermissionModeKey =
(existingPermissionMode && existingPermissionMode !== 'default' ? existingPermissionMode : undefined) ||
@@ -327,7 +332,8 @@ export const storage = create()((set, get) => {
...session,
presence,
draft: existingDraft || savedDraft || session.draft || null,
- permissionMode: resolvedPermissionMode
+ permissionMode: resolvedPermissionMode,
+ customName: existingCustomName || savedCustomName || session.customName || null
};
});
@@ -807,6 +813,44 @@ export const storage = create()((set, get) => {
sessions: updatedSessions
};
}),
+ updateSessionCustomName: (sessionId: string, customName: string | null) => set((state) => {
+ const session = state.sessions[sessionId];
+ if (!session) return state;
+
+ const normalizedName = customName?.trim() ? customName.trim() : null;
+
+ // Collect all custom names for persistence
+ const allNames: Record = {};
+ Object.entries(state.sessions).forEach(([id, sess]) => {
+ if (id === sessionId) {
+ if (normalizedName) {
+ allNames[id] = normalizedName;
+ }
+ } else if (sess.customName) {
+ allNames[id] = sess.customName;
+ }
+ });
+
+ // Persist custom names
+ saveSessionCustomNames(allNames);
+
+ const updatedSessions = {
+ ...state.sessions,
+ [sessionId]: {
+ ...session,
+ customName: normalizedName
+ }
+ };
+
+ // Rebuild sessionListViewData so the UI reflects the new custom name
+ const sessionListViewData = buildSessionListViewData(updatedSessions);
+
+ return {
+ ...state,
+ sessions: updatedSessions,
+ sessionListViewData
+ };
+ }),
updateSessionModelMode: (sessionId: string, mode: string) => set((state) => {
const session = state.sessions[sessionId];
if (!session) return state;
@@ -930,7 +974,11 @@ export const storage = create()((set, get) => {
const modes = loadSessionPermissionModes();
delete modes[sessionId];
saveSessionPermissionModes(modes);
-
+
+ const names = loadSessionCustomNames();
+ delete names[sessionId];
+ saveSessionCustomNames(names);
+
// Rebuild sessionListViewData without the deleted session
const sessionListViewData = buildSessionListViewData(remainingSessions);
diff --git a/packages/happy-app/sources/sync/storageTypes.ts b/packages/happy-app/sources/sync/storageTypes.ts
index 643f75445..f6bb7640e 100644
--- a/packages/happy-app/sources/sync/storageTypes.ts
+++ b/packages/happy-app/sources/sync/storageTypes.ts
@@ -89,6 +89,7 @@ export interface Session {
id: string;
}>;
draft?: string | null; // Local draft message, not synced to server
+ customName?: string | null; // Local custom name for display, not synced to server
permissionMode?: string | null; // Local permission mode key, not synced to server
modelMode?: string | null; // Local model key, not synced to server
// IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing.
diff --git a/packages/happy-app/sources/sync/updateSessionCustomName.test.ts b/packages/happy-app/sources/sync/updateSessionCustomName.test.ts
new file mode 100644
index 000000000..023ea23e2
--- /dev/null
+++ b/packages/happy-app/sources/sync/updateSessionCustomName.test.ts
@@ -0,0 +1,204 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/**
+ * Tests the updateSessionCustomName logic extracted from the Zustand store.
+ * Since the full store has many dependencies, we test the core logic
+ * (normalization, persistence aggregation, session update) in isolation.
+ */
+
+const mockStorage = new Map();
+
+vi.mock('react-native-mmkv', () => ({
+ MMKV: vi.fn().mockImplementation(() => ({
+ getString: (key: string) => mockStorage.get(key) ?? undefined,
+ set: (key: string, value: string) => mockStorage.set(key, value),
+ delete: (key: string) => mockStorage.delete(key),
+ clearAll: () => mockStorage.clear(),
+ }))
+}));
+
+import { loadSessionCustomNames, saveSessionCustomNames } from './persistence';
+import type { Session } from './storageTypes';
+
+function makeSession(overrides: Partial = {}): Session {
+ return {
+ id: 'test-session',
+ seq: 1,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ active: true,
+ activeAt: Date.now(),
+ metadata: { path: '/Users/test/project', host: 'test-host' },
+ metadataVersion: 1,
+ agentState: null,
+ agentStateVersion: 0,
+ thinking: false,
+ thinkingAt: 0,
+ presence: 'online' as const,
+ ...overrides
+ };
+}
+
+/**
+ * Mirrors the updateSessionCustomName logic from storage.ts.
+ * This lets us test the normalization and persistence behavior
+ * without importing the full Zustand store.
+ */
+function applyCustomNameUpdate(
+ sessions: Record,
+ sessionId: string,
+ customName: string | null
+): { sessions: Record; persisted: Record } | null {
+ const session = sessions[sessionId];
+ if (!session) return null;
+
+ const normalizedName = customName?.trim() ? customName.trim() : null;
+
+ const allNames: Record = {};
+ Object.entries(sessions).forEach(([id, sess]) => {
+ if (id === sessionId) {
+ if (normalizedName) {
+ allNames[id] = normalizedName;
+ }
+ } else if (sess.customName) {
+ allNames[id] = sess.customName;
+ }
+ });
+
+ saveSessionCustomNames(allNames);
+
+ return {
+ sessions: {
+ ...sessions,
+ [sessionId]: { ...session, customName: normalizedName }
+ },
+ persisted: allNames
+ };
+}
+
+describe('updateSessionCustomName', () => {
+ beforeEach(() => {
+ mockStorage.clear();
+ });
+
+ describe('name normalization', () => {
+ it('should trim leading and trailing whitespace', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ const result = applyCustomNameUpdate(sessions, 's1', ' hello ');
+ expect(result!.sessions['s1'].customName).toBe('hello');
+ });
+
+ it('should normalize whitespace-only string to null', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ const result = applyCustomNameUpdate(sessions, 's1', ' ');
+ expect(result!.sessions['s1'].customName).toBeNull();
+ });
+
+ it('should normalize empty string to null', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ const result = applyCustomNameUpdate(sessions, 's1', '');
+ expect(result!.sessions['s1'].customName).toBeNull();
+ });
+
+ it('should normalize null to null', () => {
+ const sessions = { 's1': makeSession({ id: 's1', customName: 'Old Name' }) };
+ const result = applyCustomNameUpdate(sessions, 's1', null);
+ expect(result!.sessions['s1'].customName).toBeNull();
+ });
+
+ it('should preserve valid name as-is after trim', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ const result = applyCustomNameUpdate(sessions, 's1', 'Frontend Server');
+ expect(result!.sessions['s1'].customName).toBe('Frontend Server');
+ });
+ });
+
+ describe('session not found', () => {
+ it('should return null when session does not exist', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ const result = applyCustomNameUpdate(sessions, 'nonexistent', 'Name');
+ expect(result).toBeNull();
+ });
+
+ it('should not modify storage when session does not exist', () => {
+ const sessions = { 's1': makeSession({ id: 's1' }) };
+ saveSessionCustomNames({ 's1': 'Existing' });
+ applyCustomNameUpdate(sessions, 'nonexistent', 'Name');
+ expect(loadSessionCustomNames()).toEqual({ 's1': 'Existing' });
+ });
+ });
+
+ describe('persistence aggregation', () => {
+ it('should persist only the updated session when no other custom names exist', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1' }),
+ 's2': makeSession({ id: 's2' })
+ };
+ const result = applyCustomNameUpdate(sessions, 's1', 'My Name');
+ expect(result!.persisted).toEqual({ 's1': 'My Name' });
+ });
+
+ it('should preserve existing custom names for other sessions', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1', customName: 'First' }),
+ 's2': makeSession({ id: 's2', customName: 'Second' }),
+ 's3': makeSession({ id: 's3' })
+ };
+ const result = applyCustomNameUpdate(sessions, 's1', 'Updated First');
+ expect(result!.persisted).toEqual({
+ 's1': 'Updated First',
+ 's2': 'Second'
+ });
+ });
+
+ it('should remove session from persistence when name is cleared', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1', customName: 'First' }),
+ 's2': makeSession({ id: 's2', customName: 'Second' })
+ };
+ const result = applyCustomNameUpdate(sessions, 's1', null);
+ expect(result!.persisted).toEqual({ 's2': 'Second' });
+ });
+
+ it('should persist empty object when last custom name is cleared', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1', customName: 'Only Name' })
+ };
+ const result = applyCustomNameUpdate(sessions, 's1', '');
+ expect(result!.persisted).toEqual({});
+ });
+
+ it('should write aggregated names to MMKV', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1', customName: 'First' }),
+ 's2': makeSession({ id: 's2' })
+ };
+ applyCustomNameUpdate(sessions, 's2', 'Second');
+ expect(loadSessionCustomNames()).toEqual({
+ 's1': 'First',
+ 's2': 'Second'
+ });
+ });
+ });
+
+ describe('session update', () => {
+ it('should only modify the target session', () => {
+ const sessions = {
+ 's1': makeSession({ id: 's1' }),
+ 's2': makeSession({ id: 's2' })
+ };
+ const result = applyCustomNameUpdate(sessions, 's1', 'New Name');
+ expect(result!.sessions['s1'].customName).toBe('New Name');
+ expect(result!.sessions['s2'].customName).toBeUndefined();
+ });
+
+ it('should not modify other session properties', () => {
+ const original = makeSession({ id: 's1', metadata: { path: '/test', host: 'host' } });
+ const sessions = { 's1': original };
+ const result = applyCustomNameUpdate(sessions, 's1', 'Renamed');
+ expect(result!.sessions['s1'].metadata).toEqual(original.metadata);
+ expect(result!.sessions['s1'].id).toBe('s1');
+ expect(result!.sessions['s1'].active).toBe(original.active);
+ });
+ });
+});
diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts
index c3a0d673a..5279f42d6 100644
--- a/packages/happy-app/sources/text/_default.ts
+++ b/packages/happy-app/sources/text/_default.ts
@@ -374,7 +374,9 @@ export const en = {
deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.',
failedToDeleteSession: 'Failed to delete session',
sessionDeleted: 'Session deleted successfully',
-
+ renameSession: 'Rename Session',
+ renameSessionPrompt: 'Enter a new name for this session',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts
index 76d928500..59b6aa9aa 100644
--- a/packages/happy-app/sources/text/translations/ca.ts
+++ b/packages/happy-app/sources/text/translations/ca.ts
@@ -375,7 +375,9 @@ export const ca: TranslationStructure = {
deleteSessionWarning: 'Aquesta acció no es pot desfer. Tots els missatges i dades associats amb aquesta sessió s\'eliminaran permanentment.',
failedToDeleteSession: 'Error en eliminar la sessió',
sessionDeleted: 'Sessió eliminada amb èxit',
-
+ renameSession: 'Canvia el nom de la sessió',
+ renameSessionPrompt: 'Introdueix un nou nom per a aquesta sessió',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts
index b86aa4af6..deadb630e 100644
--- a/packages/happy-app/sources/text/translations/en.ts
+++ b/packages/happy-app/sources/text/translations/en.ts
@@ -390,6 +390,8 @@ export const en: TranslationStructure = {
deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.',
failedToDeleteSession: 'Failed to delete session',
sessionDeleted: 'Session deleted successfully',
+ renameSession: 'Rename Session',
+ renameSessionPrompt: 'Enter a new name for this session',
},
diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts
index 41c17664e..e7bfe02ec 100644
--- a/packages/happy-app/sources/text/translations/es.ts
+++ b/packages/happy-app/sources/text/translations/es.ts
@@ -375,7 +375,9 @@ export const es: TranslationStructure = {
deleteSessionWarning: 'Esta acción no se puede deshacer. Todos los mensajes y datos asociados con esta sesión se eliminarán permanentemente.',
failedToDeleteSession: 'Error al eliminar la sesión',
sessionDeleted: 'Sesión eliminada exitosamente',
-
+ renameSession: 'Renombrar sesión',
+ renameSessionPrompt: 'Introduce un nuevo nombre para esta sesión',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts
index 6dbfb52ac..fd1a03bbe 100644
--- a/packages/happy-app/sources/text/translations/it.ts
+++ b/packages/happy-app/sources/text/translations/it.ts
@@ -404,7 +404,9 @@ export const it: TranslationStructure = {
deleteSessionWarning: 'Questa azione non può essere annullata. Tutti i messaggi e i dati associati a questa sessione verranno eliminati definitivamente.',
failedToDeleteSession: 'Impossibile eliminare la sessione',
sessionDeleted: 'Sessione eliminata con successo',
-
+ renameSession: 'Rinomina sessione',
+ renameSessionPrompt: 'Inserisci un nuovo nome per questa sessione',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts
index 090480d7a..446223f81 100644
--- a/packages/happy-app/sources/text/translations/ja.ts
+++ b/packages/happy-app/sources/text/translations/ja.ts
@@ -407,6 +407,8 @@ export const ja: TranslationStructure = {
deleteSessionWarning: 'この操作は取り消せません。このセッションに関連するすべてのメッセージとデータが完全に削除されます。',
failedToDeleteSession: 'セッションの削除に失敗しました',
sessionDeleted: 'セッションが正常に削除されました',
+ renameSession: 'セッション名を変更',
+ renameSessionPrompt: 'このセッションの新しい名前を入力',
},
diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts
index 55401af6a..a30c727a6 100644
--- a/packages/happy-app/sources/text/translations/pl.ts
+++ b/packages/happy-app/sources/text/translations/pl.ts
@@ -386,6 +386,8 @@ export const pl: TranslationStructure = {
deleteSessionWarning: 'Ta operacja jest nieodwracalna. Wszystkie wiadomości i dane powiązane z tą sesją zostaną trwale usunięte.',
failedToDeleteSession: 'Nie udało się usunąć sesji',
sessionDeleted: 'Sesja została pomyślnie usunięta',
+ renameSession: 'Zmień nazwę sesji',
+ renameSessionPrompt: 'Wprowadź nową nazwę dla tej sesji',
},
components: {
diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts
index 75d9afed2..92b1256ba 100644
--- a/packages/happy-app/sources/text/translations/pt.ts
+++ b/packages/happy-app/sources/text/translations/pt.ts
@@ -375,7 +375,9 @@ export const pt: TranslationStructure = {
deleteSessionWarning: 'Esta ação não pode ser desfeita. Todas as mensagens e dados associados a esta sessão serão excluídos permanentemente.',
failedToDeleteSession: 'Falha ao excluir sessão',
sessionDeleted: 'Sessão excluída com sucesso',
-
+ renameSession: 'Renomear sessão',
+ renameSessionPrompt: 'Digite um novo nome para esta sessão',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts
index 930474bb7..acb4c0e26 100644
--- a/packages/happy-app/sources/text/translations/ru.ts
+++ b/packages/happy-app/sources/text/translations/ru.ts
@@ -349,6 +349,8 @@ export const ru: TranslationStructure = {
deleteSessionWarning: 'Это действие нельзя отменить. Все сообщения и данные, связанные с этой сессией, будут удалены навсегда.',
failedToDeleteSession: 'Не удалось удалить сессию',
sessionDeleted: 'Сессия успешно удалена',
+ renameSession: 'Переименовать сессию',
+ renameSessionPrompt: 'Введите новое название для этой сессии',
},
components: {
diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts
index a9db3ead3..814c2dd99 100644
--- a/packages/happy-app/sources/text/translations/zh-Hans.ts
+++ b/packages/happy-app/sources/text/translations/zh-Hans.ts
@@ -377,7 +377,9 @@ export const zhHans: TranslationStructure = {
deleteSessionWarning: '此操作无法撤销。与此会话相关的所有消息和数据将被永久删除。',
failedToDeleteSession: '删除会话失败',
sessionDeleted: '会话删除成功',
-
+ renameSession: '重命名会话',
+ renameSessionPrompt: '输入此会话的新名称',
+
},
components: {
diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts
index e09ca6f3c..d209b531d 100644
--- a/packages/happy-app/sources/text/translations/zh-Hant.ts
+++ b/packages/happy-app/sources/text/translations/zh-Hant.ts
@@ -376,6 +376,8 @@ export const zhHant: TranslationStructure = {
deleteSessionWarning: '此操作無法復原。與此工作階段相關的所有訊息和資料將被永久刪除。',
failedToDeleteSession: '刪除工作階段失敗',
sessionDeleted: '工作階段刪除成功',
+ renameSession: '重新命名工作階段',
+ renameSessionPrompt: '輸入此工作階段的新名稱',
},
diff --git a/packages/happy-app/sources/utils/sessionUtils.test.ts b/packages/happy-app/sources/utils/sessionUtils.test.ts
new file mode 100644
index 000000000..8866579c1
--- /dev/null
+++ b/packages/happy-app/sources/utils/sessionUtils.test.ts
@@ -0,0 +1,203 @@
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('@/text', () => ({
+ t: (key: string) => key
+}));
+
+import { getSessionName, getSessionDefaultName, getSessionCustomName } from './sessionUtils';
+import type { Session } from '@/sync/storageTypes';
+
+function makeSession(overrides: Partial = {}): Session {
+ return {
+ id: 'test-session',
+ seq: 1,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ active: true,
+ activeAt: Date.now(),
+ metadata: { path: '/Users/test/project', host: 'test-host' },
+ metadataVersion: 1,
+ agentState: null,
+ agentStateVersion: 0,
+ thinking: false,
+ thinkingAt: 0,
+ presence: 'online' as const,
+ ...overrides
+ };
+}
+
+describe('sessionUtils', () => {
+ describe('getSessionCustomName', () => {
+ it('should return the custom name when set', () => {
+ const session = makeSession({ customName: 'My Custom Name' });
+ expect(getSessionCustomName(session)).toBe('My Custom Name');
+ });
+
+ it('should return null when customName is null', () => {
+ const session = makeSession({ customName: null });
+ expect(getSessionCustomName(session)).toBeNull();
+ });
+
+ it('should return null when customName is undefined', () => {
+ const session = makeSession({ customName: undefined });
+ expect(getSessionCustomName(session)).toBeNull();
+ });
+
+ it('should return null when customName is empty string', () => {
+ const session = makeSession({ customName: '' });
+ expect(getSessionCustomName(session)).toBeNull();
+ });
+ });
+
+ describe('getSessionDefaultName', () => {
+ describe('summary text', () => {
+ it('should return summary text when available', () => {
+ const session = makeSession({
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Fix login bug', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionDefaultName(session)).toBe('Fix login bug');
+ });
+
+ it('should return summary even when customName is set', () => {
+ const session = makeSession({
+ customName: 'Custom',
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Summary text', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionDefaultName(session)).toBe('Summary text');
+ });
+ });
+
+ describe('path fallback', () => {
+ it('should return last path segment when no summary', () => {
+ const session = makeSession({
+ metadata: { path: '/Users/test/my-project', host: 'test-host' }
+ });
+ expect(getSessionDefaultName(session)).toBe('my-project');
+ });
+
+ it('should handle deeply nested paths', () => {
+ const session = makeSession({
+ metadata: { path: '/Users/test/code/repos/my-app', host: 'test-host' }
+ });
+ expect(getSessionDefaultName(session)).toBe('my-app');
+ });
+
+ it('should handle single-segment paths', () => {
+ const session = makeSession({
+ metadata: { path: '/project', host: 'test-host' }
+ });
+ expect(getSessionDefaultName(session)).toBe('project');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should return status.unknown when metadata is null', () => {
+ const session = makeSession({ metadata: null });
+ expect(getSessionDefaultName(session)).toBe('status.unknown');
+ });
+
+ it('should return status.unknown when path has no segments', () => {
+ const session = makeSession({
+ metadata: { path: '/', host: 'test-host' }
+ });
+ expect(getSessionDefaultName(session)).toBe('status.unknown');
+ });
+ });
+ });
+
+ describe('getSessionName', () => {
+ describe('custom name priority', () => {
+ it('should return customName when set', () => {
+ const session = makeSession({ customName: 'My Custom Name' });
+ expect(getSessionName(session)).toBe('My Custom Name');
+ });
+
+ it('should prioritize customName over summary text', () => {
+ const session = makeSession({
+ customName: 'Priority Name',
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Summary text', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionName(session)).toBe('Priority Name');
+ });
+
+ it('should prioritize customName over path-based name', () => {
+ const session = makeSession({
+ customName: 'Custom',
+ metadata: { path: '/Users/test/my-project', host: 'test-host' }
+ });
+ expect(getSessionName(session)).toBe('Custom');
+ });
+ });
+
+ describe('fallback to default name', () => {
+ it('should return summary text when no customName is set', () => {
+ const session = makeSession({
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Fix login bug', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionName(session)).toBe('Fix login bug');
+ });
+
+ it('should return last path segment when no customName and no summary', () => {
+ const session = makeSession({
+ metadata: { path: '/Users/test/my-project', host: 'test-host' }
+ });
+ expect(getSessionName(session)).toBe('my-project');
+ });
+
+ it('should return status.unknown when metadata is null', () => {
+ const session = makeSession({ metadata: null });
+ expect(getSessionName(session)).toBe('status.unknown');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should ignore null customName and fall through to summary', () => {
+ const session = makeSession({
+ customName: null,
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Some summary', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionName(session)).toBe('Some summary');
+ });
+
+ it('should ignore empty string customName and fall through to summary', () => {
+ const session = makeSession({
+ customName: '',
+ metadata: {
+ path: '/Users/test/project',
+ host: 'test-host',
+ summary: { text: 'Some summary', updatedAt: Date.now() }
+ }
+ });
+ expect(getSessionName(session)).toBe('Some summary');
+ });
+
+ it('should ignore undefined customName and fall through to path', () => {
+ const session = makeSession({
+ customName: undefined,
+ metadata: { path: '/Users/test/fallback-project', host: 'test-host' }
+ });
+ expect(getSessionName(session)).toBe('fallback-project');
+ });
+ });
+ });
+});
diff --git a/packages/happy-app/sources/utils/sessionUtils.ts b/packages/happy-app/sources/utils/sessionUtils.ts
index 752d2010e..0acf740e9 100644
--- a/packages/happy-app/sources/utils/sessionUtils.ts
+++ b/packages/happy-app/sources/utils/sessionUtils.ts
@@ -73,13 +73,21 @@ export function useSessionStatus(session: Session): SessionStatus {
}
/**
- * Extracts a display name from a session's metadata path.
- * Returns the last segment of the path, or 'unknown' if no path is available.
+ * Returns the custom name for a session, or null if not set.
*/
-export function getSessionName(session: Session): string {
+export function getSessionCustomName(session: Session): string | null {
+ return session.customName || null;
+}
+
+/**
+ * Returns the default display name for a session (ignoring any custom name).
+ * Uses summary text if available, otherwise falls back to the last path segment.
+ */
+export function getSessionDefaultName(session: Session): string {
if (session.metadata?.summary) {
return session.metadata.summary.text;
- } else if (session.metadata) {
+ }
+ if (session.metadata) {
const segments = session.metadata.path.split('/').filter(Boolean);
const lastSegment = segments.pop();
if (!lastSegment) {
@@ -90,6 +98,14 @@ export function getSessionName(session: Session): string {
return t('status.unknown');
}
+/**
+ * Returns the display name for a session.
+ * Checks custom name first, then falls back to the default name.
+ */
+export function getSessionName(session: Session): string {
+ return getSessionCustomName(session) || getSessionDefaultName(session);
+}
+
/**
* Generates a deterministic avatar ID from machine ID and path.
* This ensures the same machine + path combination always gets the same avatar.