diff --git a/packages/happy-app/sources/-session/SessionView.tsx b/packages/happy-app/sources/-session/SessionView.tsx index 044416fa37..d9ffa3af26 100644 --- a/packages/happy-app/sources/-session/SessionView.tsx +++ b/packages/happy-app/sources/-session/SessionView.tsx @@ -19,7 +19,7 @@ import { voiceHooks } from '@/realtime/hooks/voiceHooks'; import { startRealtimeSession, stopRealtimeSession } from '@/realtime/RealtimeSession'; import { gitStatusSync } from '@/sync/gitStatusSync'; import { sessionAbort } from '@/sync/ops'; -import { storage, useIsDataReady, useLocalSetting, useRealtimeStatus, useSessionMessages, useSessionUsage, useSetting } from '@/sync/storage'; +import { storage, useIsDataReady, useLocalSetting, useMachine, useRealtimeStatus, useSessionMessages, useSessionUsage, useSetting } from '@/sync/storage'; import { useSession } from '@/sync/storage'; import { Session } from '@/sync/storageTypes'; import { sync } from '@/sync/sync'; @@ -27,7 +27,7 @@ import { t } from '@/text'; import { tracking, trackMessageSent } from '@/track'; import { isRunningOnMac } from '@/utils/platform'; import { useDeviceType, useHeaderHeight, useIsLandscape, useIsTablet } from '@/utils/responsive'; -import { formatPathRelativeToHome, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; +import { formatPathRelativeToHome, getMachineDisplayName, getSessionAvatarId, getSessionName, useSessionStatus } from '@/utils/sessionUtils'; import { isVersionSupported, MINIMUM_CLI_VERSION } from '@/utils/versionUtils'; import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; @@ -51,6 +51,10 @@ export const SessionView = React.memo((props: { id: string }) => { const realtimeStatus = useRealtimeStatus(); const isTablet = useIsTablet(); + // Get machine for display name + const machineId = session?.metadata?.machineId; + const machine = useMachine(machineId || ''); + // Compute header props based on session state const headerProps = useMemo(() => { if (!isDataReady) { @@ -79,16 +83,20 @@ export const SessionView = React.memo((props: { id: string }) => { // Normal state - show session info const isConnected = session.presence === 'online'; + const formattedPath = session.metadata?.path ? formatPathRelativeToHome(session.metadata.path, session.metadata?.homeDir) : undefined; + const machineDisplayName = getMachineDisplayName(session, machine); + const subtitle = formattedPath && machineDisplayName ? `${machineDisplayName}:${formattedPath}` : formattedPath; + return { title: getSessionName(session), - subtitle: session.metadata?.path ? formatPathRelativeToHome(session.metadata.path, session.metadata?.homeDir) : undefined, + subtitle, avatarId: getSessionAvatarId(session), onAvatarPress: () => router.push(`/session/${sessionId}/info`), isConnected: isConnected, flavor: session.metadata?.flavor || null, tintColor: isConnected ? '#000' : '#8E8E93' }; - }, [session, isDataReady, sessionId, router]); + }, [session, isDataReady, sessionId, router, machine]); return ( <> diff --git a/packages/happy-app/sources/components/ActiveSessionsGroup.tsx b/packages/happy-app/sources/components/ActiveSessionsGroup.tsx index d567b9fb97..db265e486c 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, useSessionStatus, getSessionAvatarId, formatPathRelativeToHome, getMachineDisplayName } from '@/utils/sessionUtils'; import { Avatar } from './Avatar'; import { Typography } from '@/constants/Typography'; import { StatusDot } from './StatusDot'; @@ -94,7 +94,13 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ sessionTitleRow: { flexDirection: 'row', alignItems: 'center', + marginBottom: 2, + }, + sessionSubtitle: { + fontSize: 12, + color: theme.colors.textSecondary, marginBottom: 4, + ...Typography.default(), }, sessionTitle: { fontSize: 15, @@ -339,6 +345,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi const styles = stylesheet; const sessionStatus = useSessionStatus(session); const sessionName = getSessionName(session); + const hostname = getMachineDisplayName(session); const navigateToSession = useNavigateToSession(); const isTablet = useIsTablet(); const swipeableRef = React.useRef(null); @@ -406,6 +413,13 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi + {/* Hostname subtitle */} + {hostname && ( + + {hostname} + + )} + {/* Status line with dot */} diff --git a/packages/happy-app/sources/utils/machineDisplay.spec.ts b/packages/happy-app/sources/utils/machineDisplay.spec.ts new file mode 100644 index 0000000000..b7bfcd7284 --- /dev/null +++ b/packages/happy-app/sources/utils/machineDisplay.spec.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { getMachineDisplayName, type MinimalSession, type MinimalMachine } from './machineDisplay'; + +describe('machineDisplay', () => { + describe('getMachineDisplayName', () => { + const baseSession: MinimalSession = { + metadata: { + host: 'lute.example.com', + machineId: 'machine-1' + } + }; + + const baseMachine: MinimalMachine = { + metadata: { + host: 'lute.example.com' + } + }; + + describe('priority 1: Machine displayName', () => { + it('should use machine displayName when available', () => { + const machine: MinimalMachine = { + metadata: { + host: 'lute.example.com', + displayName: 'My MacBook Pro' + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('My MacBook Pro'); + }); + + it('should prioritize displayName over hostname', () => { + const machine: MinimalMachine = { + metadata: { + host: 'lute.example.com', + displayName: 'Dev Server' + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('Dev Server'); + }); + }); + + describe('priority 2: Machine short hostname', () => { + it('should use short hostname from machine metadata', () => { + const machine: MinimalMachine = { + metadata: { + host: 'lute.example.com' + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('lute'); + }); + + it('should return full hostname if no dots', () => { + const machine: MinimalMachine = { + metadata: { + host: 'localhost' + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('localhost'); + }); + + it('should handle hostname with multiple dots', () => { + const machine: MinimalMachine = { + metadata: { + host: 'web01.prod.example.com' + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('web01'); + }); + }); + + describe('priority 3: Session metadata host fallback', () => { + it('should fall back to session metadata host when machine is null', () => { + expect(getMachineDisplayName(baseSession, null)).toBe('lute'); + }); + + it('should fall back to session metadata host when machine is undefined', () => { + expect(getMachineDisplayName(baseSession, undefined)).toBe('lute'); + }); + + it('should fall back when machine metadata is null', () => { + const machine: MinimalMachine = { + metadata: null + }; + expect(getMachineDisplayName(baseSession, machine)).toBe('lute'); + }); + + it('should extract short hostname from FQDN in session', () => { + const session: MinimalSession = { + metadata: { + host: 'server.internal.company.com', + machineId: 'machine-1' + } + }; + expect(getMachineDisplayName(session, null)).toBe('server'); + }); + }); + + describe('edge cases', () => { + it('should return undefined when no hostname available', () => { + const session: MinimalSession = { + metadata: null + }; + expect(getMachineDisplayName(session, null)).toBeUndefined(); + }); + + it('should return undefined when session metadata has empty host', () => { + const session: MinimalSession = { + metadata: { + host: '', + machineId: 'machine-1' + } + }; + expect(getMachineDisplayName(session, null)).toBeUndefined(); + }); + + it('should handle empty displayName by falling back', () => { + const machine: MinimalMachine = { + metadata: { + host: 'lute.example.com', + displayName: '' + } + }; + // Empty string is falsy, should fall back to hostname + expect(getMachineDisplayName(baseSession, machine)).toBe('lute'); + }); + }); + + describe('common scenarios', () => { + it('should work for localhost', () => { + const session: MinimalSession = { + metadata: { + host: 'localhost' + } + }; + expect(getMachineDisplayName(session, null)).toBe('localhost'); + }); + + it('should work for remote server with FQDN', () => { + const session: MinimalSession = { + metadata: { + host: 'prod-server-01.us-west-2.company.com' + } + }; + expect(getMachineDisplayName(session, null)).toBe('prod-server-01'); + }); + + it('should work with custom machine names', () => { + const machine: MinimalMachine = { + metadata: { + host: 'macbook.local', + displayName: "Jon's MacBook Pro" + } + }; + expect(getMachineDisplayName(baseSession, machine)).toBe("Jon's MacBook Pro"); + }); + }); + + describe('integration examples', () => { + it('should combine with path for complete subtitle', () => { + const session: MinimalSession = { + metadata: { + host: 'lute.example.com', + machineId: 'machine-1' + } + }; + const path = '~/projects/myapp'; + const hostname = getMachineDisplayName(session, null); + + expect(hostname).toBe('lute'); + expect(`${hostname}:${path}`).toBe('lute:~/projects/myapp'); + }); + + it('should work with custom display name in subtitle', () => { + const session: MinimalSession = { + metadata: { + host: 'macbook.local', + machineId: 'machine-1' + } + }; + const machine: MinimalMachine = { + metadata: { + host: 'macbook.local', + displayName: 'My MacBook' + } + }; + const path = '~/Code/happy'; + const hostname = getMachineDisplayName(session, machine); + + expect(hostname).toBe('My MacBook'); + expect(`${hostname}:${path}`).toBe('My MacBook:~/Code/happy'); + }); + }); + }); +}); diff --git a/packages/happy-app/sources/utils/machineDisplay.ts b/packages/happy-app/sources/utils/machineDisplay.ts new file mode 100644 index 0000000000..c2be21c283 --- /dev/null +++ b/packages/happy-app/sources/utils/machineDisplay.ts @@ -0,0 +1,47 @@ +/** + * Pure utility functions for machine/hostname display logic. + * No React or React Native dependencies - testable in node environment. + */ + +export type MinimalSession = { + metadata: { + host: string; + machineId?: string; + } | null; +}; + +export type MinimalMachine = { + metadata: { + host: string; + displayName?: string; + } | null; +}; + +/** + * Gets the display name for a machine/host. + * Prioritizes: Machine.displayName > short hostname > session.metadata.host + * @param session - The session containing metadata + * @param machine - Optional machine object + * @returns Display name for the machine, or undefined if not available + */ +export function getMachineDisplayName( + session: MinimalSession, + machine?: MinimalMachine | null +): string | undefined { + // Priority 1: Use machine's custom display name if available + if (machine?.metadata?.displayName) { + return machine.metadata.displayName; + } + + // Priority 2: Use machine's short hostname + if (machine?.metadata?.host) { + return machine.metadata.host.split('.')[0]; + } + + // Priority 3: Fall back to session metadata host (short format) + if (session.metadata?.host) { + return session.metadata.host.split('.')[0]; + } + + return undefined; +} diff --git a/packages/happy-app/sources/utils/sessionUtils.ts b/packages/happy-app/sources/utils/sessionUtils.ts index 752d2010e6..f0826beba7 100644 --- a/packages/happy-app/sources/utils/sessionUtils.ts +++ b/packages/happy-app/sources/utils/sessionUtils.ts @@ -1,6 +1,7 @@ import * as React from 'react'; -import { Session } from '@/sync/storageTypes'; +import { Session, Machine } from '@/sync/storageTypes'; import { t } from '@/text'; +import { getMachineDisplayName as getMachineDisplayNamePure } from '@/utils/machineDisplay'; export type SessionState = 'disconnected' | 'thinking' | 'waiting' | 'permission_required'; @@ -103,6 +104,17 @@ export function getSessionAvatarId(session: Session): string { return session.id; } +/** + * Gets the display name for a machine/host. + * Wrapper around pure utility function in machineDisplay.ts + * @param session - The session containing metadata + * @param machine - Optional machine object (from useMachine hook) + * @returns Display name for the machine, or undefined if not available + */ +export function getMachineDisplayName(session: Session, machine?: Machine | null): string | undefined { + return getMachineDisplayNamePure(session, machine); +} + /** * Formats a path relative to home directory if possible. * If the path starts with the home directory, replaces it with ~ @@ -110,11 +122,11 @@ export function getSessionAvatarId(session: Session): string { */ export function formatPathRelativeToHome(path: string, homeDir?: string): string { if (!homeDir) return path; - + // Normalize paths to handle trailing slashes const normalizedHome = homeDir.endsWith('/') ? homeDir.slice(0, -1) : homeDir; const normalizedPath = path; - + // Check if path starts with home directory if (normalizedPath.startsWith(normalizedHome)) { // Replace home directory with ~ @@ -128,16 +140,21 @@ export function formatPathRelativeToHome(path: string, homeDir?: string): string return '~/' + relativePath; } } - + return path; } /** - * Returns the session path for the subtitle. + * Returns the session subtitle: "hostname:~/path" if hostname is available, else just "~/path". */ -export function getSessionSubtitle(session: Session): string { +export function getSessionSubtitle(session: Session, machine?: Machine | null): string { if (session.metadata) { - return formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); + const path = formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); + const hostname = getMachineDisplayName(session, machine); + if (hostname) { + return `${hostname}:${path}`; + } + return path; } return t('status.unknown'); }