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.