diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e741060..8d370fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ## [Unreleased] ### Added +- `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Stored in the on-disk config so it persists across restarts. + + - Strict IPC input validation guards for project/session/subagent/search limits. - `get-waterfall-data` IPC endpoint implementation. - Cross-platform path normalization in renderer path resolvers. diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 469d7c33..b860bd15 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -203,6 +203,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'theme', 'defaultTab', 'claudeRootPath', + 'autoExpandAIGroups', ]; const result: Partial = {}; @@ -267,6 +268,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V result.claudeRootPath = path.resolve(normalized); } break; + case 'autoExpandAIGroups': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.autoExpandAIGroups = value; + break; default: return { valid: false, error: `Unsupported general key: ${key}` }; } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index b8999536..7f3ea4a3 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -181,6 +181,7 @@ export interface GeneralConfig { theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; + autoExpandAIGroups: boolean; } export interface DisplayConfig { @@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = { theme: 'dark', defaultTab: 'dashboard', claudeRootPath: null, + autoExpandAIGroups: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 6d3f9c6a..8f4fbdc4 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -30,6 +30,7 @@ export interface SafeConfig { theme: 'dark' | 'light' | 'system'; defaultTab: 'dashboard' | 'last-session'; claudeRootPath: string | null; + autoExpandAIGroups: boolean; }; notifications: { enabled: boolean; @@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { theme: displayConfig?.general?.theme ?? 'dark', defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, + autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false, }, notifications: { enabled: displayConfig?.notifications?.enabled ?? true, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 5d6941b0..40a9997f 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -287,6 +287,7 @@ export function useSettingsHandlers({ theme: 'dark', defaultTab: 'dashboard', claudeRootPath: null, + autoExpandAIGroups: false, }, display: { showTimestamps: true, diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index cd28a176..deca9d3f 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -15,6 +15,7 @@ import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } fro import type { SafeConfig } from '../hooks/useSettingsConfig'; import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; import type { HttpServerStatus } from '@shared/types/api'; +import type { AppConfig } from '@shared/types/notifications'; // Theme options const THEME_OPTIONS = [ @@ -26,7 +27,7 @@ const THEME_OPTIONS = [ interface GeneralSectionProps { readonly safeConfig: SafeConfig; readonly saving: boolean; - readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void; + readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void; readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void; } @@ -286,6 +287,16 @@ export const GeneralSection = ({ disabled={saving} /> + + onGeneralToggle('autoExpandAIGroups', v)} + disabled={saving} + /> + {isElectron && ( <> diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 9a158f49..7a1de354 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -416,6 +416,15 @@ export const createSessionDetailSlice: StateCreator item.type === 'ai') + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id) + ); + // Update only the data, preserve UI states set((state) => ({ sessionDetail: detail, @@ -572,6 +589,29 @@ export const createSessionDetailSlice: StateCreator + item.type === 'ai' && + !oldGroupIds.has((item as { type: 'ai'; group: { id: string } }).group.id) + ) + .map((item) => (item as { type: 'ai'; group: { id: string } }).group.id); + + if (newGroupIds.length > 0) { + for (const tab of latestAllTabs) { + if (tab.type === 'session' && tab.sessionId === sessionId) { + for (const groupId of newGroupIds) { + get().expandAIGroupForTab(tab.id, groupId); + } + } + } + } + } + // Also update per-tab session data for all tabs viewing this session const latestTabSessionData = { ...get().tabSessionData }; for (const tab of latestAllTabs) { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 245663ab..10dadc50 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -262,6 +262,8 @@ export interface AppConfig { defaultTab: 'dashboard' | 'last-session'; /** Optional custom Claude root folder (auto-detected when null) */ claudeRootPath: string | null; + /** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */ + autoExpandAIGroups: boolean; }; /** Display and UI settings */ display: { diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts index 0dbd7707..4dcb9714 100644 --- a/test/main/ipc/configValidation.test.ts +++ b/test/main/ipc/configValidation.test.ts @@ -20,6 +20,28 @@ describe('configValidation', () => { } }); + it('accepts general.autoExpandAIGroups boolean toggle', () => { + const resultOn = validateConfigUpdatePayload('general', { autoExpandAIGroups: true }); + expect(resultOn.valid).toBe(true); + if (resultOn.valid) { + expect(resultOn.data).toEqual({ autoExpandAIGroups: true }); + } + + const resultOff = validateConfigUpdatePayload('general', { autoExpandAIGroups: false }); + expect(resultOff.valid).toBe(true); + if (resultOff.valid) { + expect(resultOff.data).toEqual({ autoExpandAIGroups: false }); + } + }); + + it('rejects non-boolean general.autoExpandAIGroups', () => { + const result = validateConfigUpdatePayload('general', { autoExpandAIGroups: 'yes' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('boolean'); + } + }); + it('accepts absolute general.claudeRootPath updates', () => { const result = validateConfigUpdatePayload('general', { claudeRootPath: '/Users/test/.claude',