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:
parent
9c04e90fdd
commit
93b515af40
9 changed files with 91 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}` };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue