From 9740608fa83f1cde9e83654b7aac72d04e0ad0ab Mon Sep 17 00:00:00 2001 From: Alanna Scott <130878+ascott@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:13:00 -0800 Subject: [PATCH] feat: add session rename with swipe-left action (#290) Add ability to rename active sessions by swiping left and tapping the Rename button alongside the existing Archive button. Custom names are stored locally via MMKV and persist across app restarts and sync updates. - Add customName field to Session type with MMKV persistence - Add updateSessionCustomName store action with whitespace normalization - Add getSessionName/getSessionDefaultName/getSessionCustomName utils - Add Rename button to swipe-left actions on active sessions - Add maxLength support to Modal.prompt - Clean up custom names on session delete - Add translations for all 11 languages - Add 49 tests covering utils, persistence, and store actions Co-Authored-By: Claude Opus 4.6 --- .../components/ActiveSessionsGroup.tsx | 66 ++++-- .../components/ActiveSessionsGroupCompact.tsx | 66 ++++-- .../sources/components/SessionsList.tsx | 1 - .../happy-app/sources/modal/ModalManager.ts | 8 +- .../modal/components/WebPromptModal.tsx | 1 + packages/happy-app/sources/modal/types.ts | 2 + .../sources/sync/persistence.test.ts | 142 ++++++++++++ .../happy-app/sources/sync/persistence.ts | 17 ++ packages/happy-app/sources/sync/storage.ts | 54 ++++- .../happy-app/sources/sync/storageTypes.ts | 1 + .../sync/updateSessionCustomName.test.ts | 204 ++++++++++++++++++ packages/happy-app/sources/text/_default.ts | 4 +- .../happy-app/sources/text/translations/ca.ts | 4 +- .../happy-app/sources/text/translations/en.ts | 2 + .../happy-app/sources/text/translations/es.ts | 4 +- .../happy-app/sources/text/translations/it.ts | 4 +- .../happy-app/sources/text/translations/ja.ts | 2 + .../happy-app/sources/text/translations/pl.ts | 2 + .../happy-app/sources/text/translations/pt.ts | 4 +- .../happy-app/sources/text/translations/ru.ts | 2 + .../sources/text/translations/zh-Hans.ts | 4 +- .../sources/text/translations/zh-Hant.ts | 2 + .../sources/utils/sessionUtils.test.ts | 203 +++++++++++++++++ .../happy-app/sources/utils/sessionUtils.ts | 24 ++- 24 files changed, 782 insertions(+), 41 deletions(-) create mode 100644 packages/happy-app/sources/sync/persistence.test.ts create mode 100644 packages/happy-app/sources/sync/updateSessionCustomName.test.ts create mode 100644 packages/happy-app/sources/utils/sessionUtils.test.ts 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.