diff --git a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx index 9bedf2212..0eb8d5716 100644 --- a/packages/happy-app/sources/app/(app)/session/[id]/info.tsx +++ b/packages/happy-app/sources/app/(app)/session/[id]/info.tsx @@ -404,6 +404,14 @@ function SessionInfoContent({ session }: { session: Session }) { showChevron={false} /> )} + {session.metadata.startedBy && ( + } + showChevron={false} + /> + )} {session.metadata.happyHomeDir && ( { + const baseMetadata = { + path: '/home/user/project', + host: 'my-host', + }; + + describe('startedBy field', () => { + it('accepts daemon as startedBy value', () => { + const result = MetadataSchema.safeParse({ + ...baseMetadata, + startedBy: 'daemon', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startedBy).toBe('daemon'); + } + }); + + it('accepts terminal as startedBy value', () => { + const result = MetadataSchema.safeParse({ + ...baseMetadata, + startedBy: 'terminal', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startedBy).toBe('terminal'); + } + }); + + it('accepts metadata without startedBy (optional)', () => { + const result = MetadataSchema.safeParse(baseMetadata); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.startedBy).toBeUndefined(); + } + }); + + it('rejects invalid startedBy values', () => { + const result = MetadataSchema.safeParse({ + ...baseMetadata, + startedBy: 'unknown', + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/happy-app/sources/sync/storageTypes.ts b/packages/happy-app/sources/sync/storageTypes.ts index 643f75445..fa634125a 100644 --- a/packages/happy-app/sources/sync/storageTypes.ts +++ b/packages/happy-app/sources/sync/storageTypes.ts @@ -39,6 +39,7 @@ export const MetadataSchema = z.object({ homeDir: z.string().optional(), // User's home directory on the machine happyHomeDir: z.string().optional(), // Happy configuration directory hostPid: z.number().optional(), // Process ID of the session + startedBy: z.enum(['daemon', 'terminal']).optional(), // How the session was started flavor: z.string().nullish(), // Session flavor/variant identifier sandbox: z.any().nullish(), // Sandbox config metadata from CLI (or null when disabled) dangerouslySkipPermissions: z.boolean().nullish(), // Claude --dangerously-skip-permissions mode (or null when unknown) diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index c3a0d673a..35c8cdfee 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -355,6 +355,9 @@ export const en = { path: 'Path', operatingSystem: 'Operating System', processId: 'Process ID', + startedBy: 'Started By', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminal', happyHome: 'Happy Home', copyMetadata: 'Copy Metadata', agentState: 'Agent State', diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 76d928500..ed431386e 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -356,6 +356,9 @@ export const ca: TranslationStructure = { path: 'Camí', operatingSystem: 'Sistema operatiu', processId: 'ID del procés', + startedBy: 'Iniciat per', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminal', happyHome: 'Directori de Happy', copyMetadata: 'Copia les metadades', agentState: 'Estat de l\'agent', diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index b86aa4af6..d481d679d 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -371,6 +371,9 @@ export const en: TranslationStructure = { path: 'Path', operatingSystem: 'Operating System', processId: 'Process ID', + startedBy: 'Started By', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminal', happyHome: 'Happy Home', copyMetadata: 'Copy Metadata', agentState: 'Agent State', diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 41c17664e..22fe8710c 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -356,6 +356,9 @@ export const es: TranslationStructure = { path: 'Ruta', operatingSystem: 'Sistema operativo', processId: 'ID del proceso', + startedBy: 'Iniciado por', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminal', happyHome: 'Directorio de Happy', copyMetadata: 'Copiar metadatos', agentState: 'Estado del agente', diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 6dbfb52ac..66df13935 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -385,6 +385,9 @@ export const it: TranslationStructure = { path: 'Percorso', operatingSystem: 'Sistema operativo', processId: 'ID processo', + startedBy: 'Avviato da', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminale', happyHome: 'Happy Home', copyMetadata: 'Copia metadati', agentState: 'Stato agente', diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 090480d7a..16edeb971 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -388,6 +388,9 @@ export const ja: TranslationStructure = { path: 'パス', operatingSystem: 'オペレーティングシステム', processId: 'プロセスID', + startedBy: '起動元', + startedByDaemon: 'デーモン', + startedByTerminal: 'ターミナル', happyHome: 'Happy Home', copyMetadata: 'メタデータをコピー', agentState: 'エージェント状態', diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index 55401af6a..7422b1d46 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -367,6 +367,9 @@ export const pl: TranslationStructure = { path: 'Ścieżka', operatingSystem: 'System operacyjny', processId: 'ID procesu', + startedBy: 'Uruchomiono przez', + startedByDaemon: 'Demon', + startedByTerminal: 'Terminal', happyHome: 'Katalog domowy Happy', copyMetadata: 'Kopiuj metadane', agentState: 'Stan agenta', diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 75d9afed2..f5d13bd45 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -356,6 +356,9 @@ export const pt: TranslationStructure = { path: 'Caminho', operatingSystem: 'Sistema operacional', processId: 'ID do processo', + startedBy: 'Iniciado por', + startedByDaemon: 'Daemon', + startedByTerminal: 'Terminal', happyHome: 'Diretório Happy', copyMetadata: 'Copiar metadados', agentState: 'Estado do agente', diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 930474bb7..a16ca5856 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -330,6 +330,9 @@ export const ru: TranslationStructure = { path: 'Путь', operatingSystem: 'Операционная система', processId: 'ID процесса', + startedBy: 'Запущено', + startedByDaemon: 'Демон', + startedByTerminal: 'Терминал', happyHome: 'Домашний каталог Happy', copyMetadata: 'Копировать метаданные', agentState: 'Состояние агента', diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index a9db3ead3..19d0a8afe 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -358,6 +358,9 @@ export const zhHans: TranslationStructure = { path: '路径', operatingSystem: '操作系统', processId: '进程 ID', + startedBy: '启动方式', + startedByDaemon: '守护进程', + startedByTerminal: '终端', happyHome: 'Happy 主目录', copyMetadata: '复制元数据', agentState: 'Agent 状态', diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index e09ca6f3c..4ec227f9a 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -357,6 +357,9 @@ export const zhHant: TranslationStructure = { path: '路徑', operatingSystem: '作業系統', processId: '處理程序 ID', + startedBy: '啟動方式', + startedByDaemon: '守護程序', + startedByTerminal: '終端機', happyHome: 'Happy 主目錄', copyMetadata: '複製中繼資料', agentState: 'Agent 狀態', 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..419027b47 --- /dev/null +++ b/packages/happy-app/sources/utils/sessionUtils.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getSessionSubtitle, formatPathRelativeToHome, getSessionName } from './sessionUtils'; +import { Session } from '@/sync/storageTypes'; + +// Mock @/text to return key-based values for deterministic testing +vi.mock('@/text', () => ({ + t: (key: string) => { + const translations: Record = { + 'status.unknown': 'Unknown', + 'sessionInfo.startedByDaemon': 'Daemon', + 'sessionInfo.startedByTerminal': 'Terminal', + }; + return translations[key] || key; + } +})); + +function createSession(overrides: Partial = {}): Session { + return { + id: 'test-session-id', + seq: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + active: true, + activeAt: Date.now(), + presence: 'online', + thinking: false, + thinkingAt: 0, + metadata: { + path: '/home/user/projects/my-app', + host: 'localhost', + homeDir: '/home/user', + }, + agentState: null, + messages: [], + permissionMode: 'default', + ...overrides, + } as Session; +} + +describe('sessionUtils', () => { + describe('getSessionSubtitle', () => { + it('returns path relative to home for terminal sessions', () => { + const session = createSession(); + expect(getSessionSubtitle(session)).toBe('~/projects/my-app'); + }); + + it('appends daemon label when session was started by daemon', () => { + const session = createSession({ + metadata: { + path: '/home/user/projects/my-app', + host: 'localhost', + homeDir: '/home/user', + startedBy: 'daemon', + }, + } as Partial); + expect(getSessionSubtitle(session)).toBe('~/projects/my-app · daemon'); + }); + + it('does not append label for terminal sessions', () => { + const session = createSession({ + metadata: { + path: '/home/user/projects/my-app', + host: 'localhost', + homeDir: '/home/user', + startedBy: 'terminal', + }, + } as Partial); + expect(getSessionSubtitle(session)).toBe('~/projects/my-app'); + }); + + it('does not append label when startedBy is not set', () => { + const session = createSession(); + expect(getSessionSubtitle(session)).not.toContain('·'); + }); + + it('returns Unknown when metadata is missing', () => { + const session = createSession({ metadata: null } as Partial); + expect(getSessionSubtitle(session)).toBe('Unknown'); + }); + }); + + describe('formatPathRelativeToHome', () => { + it('replaces home dir with ~', () => { + expect(formatPathRelativeToHome('/home/user/projects', '/home/user')).toBe('~/projects'); + }); + + it('returns full path when no homeDir', () => { + expect(formatPathRelativeToHome('/home/user/projects')).toBe('/home/user/projects'); + }); + + it('returns ~ for exact home dir match', () => { + expect(formatPathRelativeToHome('/home/user', '/home/user')).toBe('~'); + }); + }); +}); diff --git a/packages/happy-app/sources/utils/sessionUtils.ts b/packages/happy-app/sources/utils/sessionUtils.ts index 752d2010e..398224983 100644 --- a/packages/happy-app/sources/utils/sessionUtils.ts +++ b/packages/happy-app/sources/utils/sessionUtils.ts @@ -137,7 +137,11 @@ export function formatPathRelativeToHome(path: string, homeDir?: string): string */ export function getSessionSubtitle(session: Session): string { if (session.metadata) { - return formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); + const path = formatPathRelativeToHome(session.metadata.path, session.metadata.homeDir); + if (session.metadata.startedBy === 'daemon') { + return `${path} · ${t('sessionInfo.startedByDaemon').toLowerCase()}`; + } + return path; } return t('status.unknown'); }