feat: add auto-expand AI response groups setting

- Add toggle in settings to auto-expand AI response groups
- Auto-expand new AI groups on live session refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
proxy 2026-02-22 19:13:28 -05:00
parent 9c04e90fdd
commit 93b515af40
9 changed files with 91 additions and 1 deletions

View file

@ -7,6 +7,9 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
## [Unreleased] ## [Unreleased]
### Added ### 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. - Strict IPC input validation guards for project/session/subagent/search limits.
- `get-waterfall-data` IPC endpoint implementation. - `get-waterfall-data` IPC endpoint implementation.
- Cross-platform path normalization in renderer path resolvers. - Cross-platform path normalization in renderer path resolvers.

View file

@ -203,6 +203,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'theme', 'theme',
'defaultTab', 'defaultTab',
'claudeRootPath', 'claudeRootPath',
'autoExpandAIGroups',
]; ];
const result: Partial<GeneralConfig> = {}; const result: Partial<GeneralConfig> = {};
@ -267,6 +268,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
result.claudeRootPath = path.resolve(normalized); result.claudeRootPath = path.resolve(normalized);
} }
break; break;
case 'autoExpandAIGroups':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
}
result.autoExpandAIGroups = value;
break;
default: default:
return { valid: false, error: `Unsupported general key: ${key}` }; return { valid: false, error: `Unsupported general key: ${key}` };
} }

View file

@ -181,6 +181,7 @@ export interface GeneralConfig {
theme: 'dark' | 'light' | 'system'; theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null; claudeRootPath: string | null;
autoExpandAIGroups: boolean;
} }
export interface DisplayConfig { export interface DisplayConfig {
@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = {
theme: 'dark', theme: 'dark',
defaultTab: 'dashboard', defaultTab: 'dashboard',
claudeRootPath: null, claudeRootPath: null,
autoExpandAIGroups: false,
}, },
display: { display: {
showTimestamps: true, showTimestamps: true,

View file

@ -30,6 +30,7 @@ export interface SafeConfig {
theme: 'dark' | 'light' | 'system'; theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null; claudeRootPath: string | null;
autoExpandAIGroups: boolean;
}; };
notifications: { notifications: {
enabled: boolean; enabled: boolean;
@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
theme: displayConfig?.general?.theme ?? 'dark', theme: displayConfig?.general?.theme ?? 'dark',
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
}, },
notifications: { notifications: {
enabled: displayConfig?.notifications?.enabled ?? true, enabled: displayConfig?.notifications?.enabled ?? true,

View file

@ -287,6 +287,7 @@ export function useSettingsHandlers({
theme: 'dark', theme: 'dark',
defaultTab: 'dashboard', defaultTab: 'dashboard',
claudeRootPath: null, claudeRootPath: null,
autoExpandAIGroups: false,
}, },
display: { display: {
showTimestamps: true, showTimestamps: true,

View file

@ -15,6 +15,7 @@ import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } fro
import type { SafeConfig } from '../hooks/useSettingsConfig'; import type { SafeConfig } from '../hooks/useSettingsConfig';
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
import type { HttpServerStatus } from '@shared/types/api'; import type { HttpServerStatus } from '@shared/types/api';
import type { AppConfig } from '@shared/types/notifications';
// Theme options // Theme options
const THEME_OPTIONS = [ const THEME_OPTIONS = [
@ -26,7 +27,7 @@ const THEME_OPTIONS = [
interface GeneralSectionProps { interface GeneralSectionProps {
readonly safeConfig: SafeConfig; readonly safeConfig: SafeConfig;
readonly saving: boolean; 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; readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
} }
@ -286,6 +287,16 @@ export const GeneralSection = ({
disabled={saving} disabled={saving}
/> />
</SettingRow> </SettingRow>
<SettingRow
label="Expand AI responses by default"
description="Automatically expand each response turn when opening a transcript or receiving a new message"
>
<SettingsToggle
enabled={safeConfig.general.autoExpandAIGroups ?? false}
onChange={(v) => onGeneralToggle('autoExpandAIGroups', v)}
disabled={saving}
/>
</SettingRow>
{isElectron && ( {isElectron && (
<> <>

View file

@ -416,6 +416,15 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
sessionPhaseInfo: phaseInfo, sessionPhaseInfo: phaseInfo,
}); });
// Auto-expand all AI groups if the setting is enabled
if (tabId && conversation?.items && get().appConfig?.general?.autoExpandAIGroups) {
for (const item of conversation.items) {
if (item.type === 'ai') {
get().expandAIGroupForTab(tabId, item.group.id);
}
}
}
// Store per-tab session data // Store per-tab session data
if (tabId) { if (tabId) {
const prev = get().tabSessionData; const prev = get().tabSessionData;
@ -554,6 +563,14 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
} }
// Snapshot existing AI group IDs before overwriting state, so the
// auto-expand diff below can correctly identify which groups are new.
const prevGroupIds = new Set(
(latestState.conversation?.items ?? [])
.filter((item) => item.type === 'ai')
.map((item) => (item as { type: 'ai'; group: { id: string } }).group.id)
);
// Update only the data, preserve UI states // Update only the data, preserve UI states
set((state) => ({ set((state) => ({
sessionDetail: detail, sessionDetail: detail,
@ -572,6 +589,29 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// so expansion states are preserved // so expansion states are preserved
})); }));
// Auto-expand newly arrived AI groups if the setting is enabled.
// Uses prevGroupIds snapshotted before set() so the diff is accurate.
if (get().appConfig?.general?.autoExpandAIGroups) {
const oldGroupIds = prevGroupIds;
const newGroupIds = newConversation.items
.filter(
(item) =>
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 // Also update per-tab session data for all tabs viewing this session
const latestTabSessionData = { ...get().tabSessionData }; const latestTabSessionData = { ...get().tabSessionData };
for (const tab of latestAllTabs) { for (const tab of latestAllTabs) {

View file

@ -262,6 +262,8 @@ export interface AppConfig {
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
/** Optional custom Claude root folder (auto-detected when null) */ /** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | 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 and UI settings */
display: { display: {

View file

@ -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', () => { it('accepts absolute general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', { const result = validateConfigUpdatePayload('general', {
claudeRootPath: '/Users/test/.claude', claudeRootPath: '/Users/test/.claude',