When a tool needs approval and the app is not focused, show a native OS notification with clear description (tool name + file/command). On macOS: notification includes Allow and Deny action buttons that respond directly without switching to the app. On all platforms: clicking the notification focuses the app window. New setting: notifyOnToolApproval (default: true) in notification settings with ShieldQuestion icon toggle.
264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
/**
|
|
* useSettingsConfig - Hook for managing settings configuration state.
|
|
* Handles loading, saving, and providing safe defaults for config.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { useStore } from '@renderer/store';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import type { AppConfig } from '@renderer/types/data';
|
|
|
|
// Get the setState function from the store to update appConfig globally
|
|
const setStoreState = useStore.setState;
|
|
|
|
/** Repository item for ignored repositories list */
|
|
export interface RepositoryDropdownItem {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
worktreeCount: number;
|
|
totalSessions: number;
|
|
}
|
|
|
|
export interface SafeConfig {
|
|
general: {
|
|
launchAtLogin: boolean;
|
|
showDockIcon: boolean;
|
|
theme: 'dark' | 'light' | 'system';
|
|
defaultTab: 'dashboard' | 'last-session';
|
|
claudeRootPath: string | null;
|
|
agentLanguage: string;
|
|
autoExpandAIGroups: boolean;
|
|
useNativeTitleBar: boolean;
|
|
};
|
|
notifications: {
|
|
enabled: boolean;
|
|
soundEnabled: boolean;
|
|
ignoredRegex: string[];
|
|
ignoredRepositories: string[];
|
|
snoozedUntil: number | null;
|
|
snoozeMinutes: number;
|
|
includeSubagentErrors: boolean;
|
|
notifyOnLeadInbox: boolean;
|
|
notifyOnUserInbox: boolean;
|
|
notifyOnClarifications: boolean;
|
|
notifyOnStatusChange: boolean;
|
|
notifyOnTaskComments: boolean;
|
|
notifyOnTaskCreated: boolean;
|
|
notifyOnAllTasksCompleted: boolean;
|
|
notifyOnCrossTeamMessage: boolean;
|
|
notifyOnTeamLaunched: boolean;
|
|
notifyOnToolApproval: boolean;
|
|
statusChangeOnlySolo: boolean;
|
|
statusChangeStatuses: string[];
|
|
triggers: AppConfig['notifications']['triggers'];
|
|
};
|
|
display: {
|
|
showTimestamps: boolean;
|
|
compactMode: boolean;
|
|
syntaxHighlighting: boolean;
|
|
};
|
|
}
|
|
|
|
interface UseSettingsConfigReturn {
|
|
config: AppConfig | null;
|
|
safeConfig: SafeConfig;
|
|
loading: boolean;
|
|
saving: boolean;
|
|
error: string | null;
|
|
setError: (error: string | null) => void;
|
|
setSaving: (saving: boolean) => void;
|
|
setConfig: (config: AppConfig | null) => void;
|
|
setOptimisticConfig: React.Dispatch<React.SetStateAction<AppConfig | null>>;
|
|
updateConfig: (
|
|
section: keyof AppConfig,
|
|
data: Partial<AppConfig[keyof AppConfig]>
|
|
) => Promise<void>;
|
|
ignoredRepositoryItems: RepositoryDropdownItem[];
|
|
excludedRepositoryIds: string[];
|
|
isSnoozed: boolean;
|
|
}
|
|
|
|
export function useSettingsConfig(): UseSettingsConfigReturn {
|
|
const { repositoryGroups, fetchRepositoryGroups } = useStore(
|
|
useShallow((s) => ({
|
|
repositoryGroups: s.repositoryGroups,
|
|
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
|
}))
|
|
);
|
|
|
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Local optimistic state for immediate visual feedback on toggles
|
|
const [optimisticConfig, setOptimisticConfig] = useState<AppConfig | null>(null);
|
|
|
|
// Fetch config on mount
|
|
useEffect(() => {
|
|
const loadConfig = async (): Promise<void> => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const loadedConfig = await api.config.get();
|
|
setConfig(loadedConfig);
|
|
setOptimisticConfig(loadedConfig);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load settings');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadConfig();
|
|
}, []);
|
|
|
|
// Fetch repository groups for ignored repositories dropdown
|
|
useEffect(() => {
|
|
if (repositoryGroups.length === 0) {
|
|
void fetchRepositoryGroups();
|
|
}
|
|
}, [repositoryGroups.length, fetchRepositoryGroups]);
|
|
|
|
// Update a config section with optimistic update for immediate UI feedback
|
|
const updateConfig = useCallback(
|
|
async (section: keyof AppConfig, data: Partial<AppConfig[keyof AppConfig]>) => {
|
|
// Optimistic update - immediately reflect the change in UI
|
|
setOptimisticConfig((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
[section]: {
|
|
...prev[section],
|
|
...data,
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
setSaving(true);
|
|
const updatedConfig = await api.config.update(section, data as object);
|
|
setConfig(updatedConfig);
|
|
setOptimisticConfig(updatedConfig);
|
|
// Update global store so other components (like useTheme) see the change
|
|
setStoreState({ appConfig: updatedConfig });
|
|
} catch (err) {
|
|
// Revert optimistic update on error
|
|
setOptimisticConfig(config);
|
|
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[config]
|
|
);
|
|
|
|
// Use optimistic config for UI display (falls back to config if not set)
|
|
const displayConfig = optimisticConfig ?? config;
|
|
|
|
// Create safe config with defaults to prevent null reference errors
|
|
const safeConfig = useMemo(
|
|
(): SafeConfig => ({
|
|
general: {
|
|
launchAtLogin: displayConfig?.general?.launchAtLogin ?? false,
|
|
showDockIcon: displayConfig?.general?.showDockIcon ?? true,
|
|
theme: displayConfig?.general?.theme ?? 'dark',
|
|
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
|
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
|
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
|
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
|
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
|
|
},
|
|
notifications: {
|
|
enabled: displayConfig?.notifications?.enabled ?? true,
|
|
soundEnabled: displayConfig?.notifications?.soundEnabled ?? true,
|
|
ignoredRegex: displayConfig?.notifications?.ignoredRegex ?? [],
|
|
ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [],
|
|
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
|
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
|
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? false,
|
|
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
|
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
|
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
|
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
|
notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
|
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
|
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
|
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
|
'in_progress',
|
|
'completed',
|
|
],
|
|
triggers: displayConfig?.notifications?.triggers ?? [],
|
|
},
|
|
display: {
|
|
showTimestamps: displayConfig?.display?.showTimestamps ?? true,
|
|
compactMode: displayConfig?.display?.compactMode ?? false,
|
|
syntaxHighlighting: displayConfig?.display?.syntaxHighlighting ?? true,
|
|
},
|
|
}),
|
|
[displayConfig]
|
|
);
|
|
|
|
// Convert ignored repository IDs to RepositoryDropdownItem[] for display
|
|
const ignoredRepositoryItems = useMemo((): RepositoryDropdownItem[] => {
|
|
const items: RepositoryDropdownItem[] = [];
|
|
const ignoredRepositories = safeConfig.notifications.ignoredRepositories;
|
|
|
|
for (const repositoryId of ignoredRepositories) {
|
|
// Find repository group by ID
|
|
const group = repositoryGroups.find((g) => g.id === repositoryId);
|
|
if (group) {
|
|
items.push({
|
|
id: group.id,
|
|
name: group.name,
|
|
path: group.worktrees[0]?.path ?? '',
|
|
worktreeCount: group.worktrees.length,
|
|
totalSessions: group.totalSessions,
|
|
});
|
|
} else {
|
|
// If not found, create a placeholder item
|
|
items.push({
|
|
id: repositoryId,
|
|
name: repositoryId,
|
|
path: '',
|
|
worktreeCount: 0,
|
|
totalSessions: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}, [safeConfig.notifications.ignoredRepositories, repositoryGroups]);
|
|
|
|
// Get excluded repository IDs for dropdown
|
|
const excludedRepositoryIds = safeConfig.notifications.ignoredRepositories;
|
|
|
|
// Check if snoozed
|
|
const isSnoozed =
|
|
safeConfig.notifications.snoozedUntil !== null &&
|
|
safeConfig.notifications.snoozedUntil > Date.now();
|
|
|
|
return {
|
|
config,
|
|
safeConfig,
|
|
loading,
|
|
saving,
|
|
error,
|
|
setError,
|
|
setSaving,
|
|
setConfig,
|
|
setOptimisticConfig,
|
|
updateConfig,
|
|
ignoredRepositoryItems,
|
|
excludedRepositoryIds,
|
|
isSnoozed,
|
|
};
|
|
}
|