diff --git a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx index 31ce1d3d..4c243fe6 100644 --- a/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/icon-picker.tsx @@ -448,7 +448,9 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) { ); const getIconComponent = (iconName: string) => { - return (LucideIcons as Record>)[iconName]; + return (LucideIcons as unknown as Record>)[ + iconName + ]; }; return ( diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx index b4434f8b..c1a2fa26 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -29,7 +29,7 @@ export function ProjectSwitcherItem({ // Get the icon component from lucide-react const getIconComponent = (): LucideIcon => { if (project.icon && project.icon in LucideIcons) { - return (LucideIcons as Record)[project.icon]; + return (LucideIcons as unknown as Record)[project.icon]; } return Folder; }; diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index 426777b5..9fa772da 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -17,6 +17,7 @@ import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { toast } from 'sonner'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; +import type { FeatureCount } from '@/components/views/spec-view/types'; function getOSAbbreviation(os: string): string { switch (os) { @@ -57,7 +58,7 @@ export function ProjectSwitcher() { const [projectOverview, setProjectOverview] = useState(''); const [generateFeatures, setGenerateFeatures] = useState(true); const [analyzeProject, setAnalyzeProject] = useState(true); - const [featureCount, setFeatureCount] = useState(5); + const [featureCount, setFeatureCount] = useState(50); // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; @@ -208,13 +209,18 @@ export function ProjectSwitcher() { try { const api = getElectronAPI(); - await api.generateAppSpec({ - projectPath: setupProjectPath, + if (!api.specRegeneration) { + toast.error('Spec regeneration not available'); + setSpecCreatingForProject(null); + return; + } + await api.specRegeneration.create( + setupProjectPath, projectOverview, generateFeatures, analyzeProject, - featureCount, - }); + featureCount + ); } catch (error) { console.error('Failed to generate spec:', error); toast.error('Failed to generate spec', { diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index 513d371b..8f3d921e 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -27,7 +27,7 @@ export function SidebarHeader({ // Get the icon component from lucide-react const getIconComponent = (): LucideIcon => { if (currentProject?.icon && currentProject.icon in LucideIcons) { - return (LucideIcons as Record)[currentProject.icon]; + return (LucideIcons as unknown as Record)[currentProject.icon]; } return Folder; }; @@ -125,7 +125,7 @@ export function SidebarHeader({ {projects.map((project) => { const ProjectIcon = project.icon && project.icon in LucideIcons - ? (LucideIcons as Record)[project.icon] + ? (LucideIcons as unknown as Record)[project.icon] : Folder; const isActive = currentProject?.id === project.id; diff --git a/apps/ui/src/components/shared/font-selector.tsx b/apps/ui/src/components/shared/font-selector.tsx new file mode 100644 index 00000000..34f346fc --- /dev/null +++ b/apps/ui/src/components/shared/font-selector.tsx @@ -0,0 +1,47 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; + +interface FontOption { + value: string; + label: string; +} + +interface FontSelectorProps { + id: string; + value: string; + options: readonly FontOption[]; + placeholder: string; + onChange: (value: string) => void; +} + +/** + * Reusable font selector component with live preview styling + */ +export function FontSelector({ id, value, options, placeholder, onChange }: FontSelectorProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/shared/index.ts b/apps/ui/src/components/shared/index.ts index 2497d409..796a945d 100644 --- a/apps/ui/src/components/shared/index.ts +++ b/apps/ui/src/components/shared/index.ts @@ -5,3 +5,6 @@ export { type UseModelOverrideOptions, type UseModelOverrideResult, } from './use-model-override'; + +// Font Components +export { FontSelector } from './font-selector'; diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index d33f60b2..10de4edb 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -90,8 +90,10 @@ const SHORTCUT_LABELS: Record = { context: 'Context', memory: 'Memory', settings: 'Settings', + projectSettings: 'Project Settings', terminal: 'Terminal', ideation: 'Ideation', + notifications: 'Notifications', githubIssues: 'GitHub Issues', githubPrs: 'Pull Requests', toggleSidebar: 'Toggle Sidebar', @@ -118,8 +120,10 @@ const SHORTCUT_CATEGORIES: Record { case 'task': return 'text-indigo-400 bg-indigo-500/10 border-indigo-500/30'; default: - return 'text-zinc-400 bg-zinc-500/10 border-zinc-500/30'; + return 'text-muted-foreground bg-muted/30 border-border'; } }; @@ -150,9 +150,9 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) { case 'in_progress': return ; case 'pending': - return ; + return ; default: - return ; + return ; } }; @@ -163,9 +163,9 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) { case 'in_progress': return 'text-amber-300'; case 'pending': - return 'text-zinc-400'; + return 'text-muted-foreground'; default: - return 'text-zinc-400'; + return 'text-muted-foreground'; } }; @@ -197,7 +197,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) { 'flex items-start gap-2 p-2 rounded-md transition-colors', todo.status === 'in_progress' && 'bg-amber-500/5 border border-amber-500/20', todo.status === 'completed' && 'bg-emerald-500/5', - todo.status === 'pending' && 'bg-zinc-800/30' + todo.status === 'pending' && 'bg-muted/30' )} >
{getStatusIcon(todo.status)}
@@ -313,9 +313,9 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { // Get colors - use tool category colors for tool_call entries const colorParts = toolCategoryColors.split(' '); - const textColor = isToolCall ? colorParts[0] || 'text-zinc-400' : colors.text; - const bgColor = isToolCall ? colorParts[1] || 'bg-zinc-500/10' : colors.bg; - const borderColor = isToolCall ? colorParts[2] || 'border-zinc-500/30' : colors.border; + const textColor = isToolCall ? colorParts[0] || 'text-muted-foreground' : colors.text; + const bgColor = isToolCall ? colorParts[1] || 'bg-muted/30' : colors.bg; + const borderColor = isToolCall ? colorParts[2] || 'border-border' : colors.border; return (
{hasContent ? ( isExpanded ? ( - + ) : ( - + ) ) : ( @@ -361,7 +361,9 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { {entry.title} - {collapsedPreview} + + {collapsedPreview} + {(isExpanded || !hasContent) && ( @@ -374,7 +376,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { {formattedContent.map((part, index) => (
{part.type === 'json' ? ( -
+                    
                       {part.content}
                     
) : ( @@ -576,7 +578,7 @@ export function LogViewer({ output, className }: LogViewerProps) {

No log entries yet. Logs will appear here as the process runs.

{output && output.trim() && ( -
+
{output}
)} @@ -610,23 +612,23 @@ export function LogViewer({ output, className }: LogViewerProps) {
{/* Sticky header with search, stats, and filters */} {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */} -
+
{/* Search bar */}
- + setSearchQuery(e.target.value)} placeholder="Search logs..." - className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600" + className="w-full pl-8 pr-8 py-1.5 text-xs bg-muted/50 border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring" data-testid="log-search-input" /> {searchQuery && (
@@ -358,25 +340,13 @@ export function ProjectThemeSection({ project }: ProjectThemeSectionProps) { - +
)}
diff --git a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx index 400d121c..f449140b 100644 --- a/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx +++ b/apps/ui/src/components/views/settings-view/appearance/appearance-section.tsx @@ -1,12 +1,5 @@ import { useState, useEffect } from 'react'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Palette, Moon, Sun, Type } from 'lucide-react'; import { darkThemes, lightThemes } from '@/config/theme-options'; import { @@ -16,6 +9,7 @@ import { } from '@/config/ui-font-options'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; +import { FontSelector } from '@/components/shared'; import type { Theme } from '../shared/types'; interface AppearanceSectionProps { @@ -165,25 +159,13 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS - +

Used for headings, labels, and UI text

@@ -194,25 +176,13 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS - +

Used for code blocks and monospaced text

diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index 4da50473..da0ef594 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -93,8 +93,11 @@ export function useProjectSettingsLoader() { } // Apply defaultDeleteBranch if present - if (result.settings.defaultDeleteBranch !== undefined) { - setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch); + if (result.settings.defaultDeleteBranchWithWorktree !== undefined) { + setDefaultDeleteBranch( + requestedProjectPath, + result.settings.defaultDeleteBranchWithWorktree + ); } // Apply autoDismissInitScriptIndicator if present diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index c9430684..ea865566 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -79,6 +79,41 @@ const SETTINGS_FIELDS_TO_SYNC = [ // Fields from setup store to sync const SETUP_FIELDS_TO_SYNC = ['isFirstRun', 'setupComplete', 'skipClaudeSetup'] as const; +/** + * Helper to extract a settings field value from app state + * Handles special cases for nested/mapped fields + */ +function getSettingsFieldValue( + field: (typeof SETTINGS_FIELDS_TO_SYNC)[number], + appState: ReturnType +): unknown { + if (field === 'currentProjectId') { + return appState.currentProject?.id ?? null; + } + if (field === 'terminalFontFamily') { + return appState.terminalState.fontFamily; + } + return appState[field as keyof typeof appState]; +} + +/** + * Helper to check if a settings field changed between states + */ +function hasSettingsFieldChanged( + field: (typeof SETTINGS_FIELDS_TO_SYNC)[number], + newState: ReturnType, + prevState: ReturnType +): boolean { + if (field === 'currentProjectId') { + return newState.currentProject?.id !== prevState.currentProject?.id; + } + if (field === 'terminalFontFamily') { + return newState.terminalState.fontFamily !== prevState.terminalState.fontFamily; + } + const key = field as keyof typeof newState; + return newState[key] !== prevState[key]; +} + interface SettingsSyncState { /** Whether initial settings have been loaded from API */ loaded: boolean; @@ -157,15 +192,7 @@ export function useSettingsSync(): SettingsSyncState { // Build updates object from current state const updates: Record = {}; for (const field of SETTINGS_FIELDS_TO_SYNC) { - if (field === 'currentProjectId') { - // Special handling: extract ID from currentProject object - updates[field] = appState.currentProject?.id ?? null; - } else if (field === 'terminalFontFamily') { - // Special handling: map terminalState.fontFamily to terminalFontFamily - updates[field] = appState.terminalState.fontFamily; - } else { - updates[field] = appState[field as keyof typeof appState]; - } + updates[field] = getSettingsFieldValue(field, appState); } // Include setup wizard state (lives in a separate store) @@ -262,13 +289,7 @@ export function useSettingsSync(): SettingsSyncState { // (migration has already hydrated the store from server/localStorage) const updates: Record = {}; for (const field of SETTINGS_FIELDS_TO_SYNC) { - if (field === 'currentProjectId') { - updates[field] = appState.currentProject?.id ?? null; - } else if (field === 'terminalFontFamily') { - updates[field] = appState.terminalState.fontFamily; - } else { - updates[field] = appState[field as keyof typeof appState]; - } + updates[field] = getSettingsFieldValue(field, appState); } for (const field of SETUP_FIELDS_TO_SYNC) { updates[field] = setupState[field as keyof typeof setupState]; @@ -322,24 +343,9 @@ export function useSettingsSync(): SettingsSyncState { // Check if any synced field changed let changed = false; for (const field of SETTINGS_FIELDS_TO_SYNC) { - if (field === 'currentProjectId') { - // Special handling: compare currentProject IDs - if (newState.currentProject?.id !== prevState.currentProject?.id) { - changed = true; - break; - } - } else if (field === 'terminalFontFamily') { - // Special handling: compare terminalState.fontFamily - if (newState.terminalState.fontFamily !== prevState.terminalState.fontFamily) { - changed = true; - break; - } - } else { - const key = field as keyof typeof newState; - if (newState[key] !== prevState[key]) { - changed = true; - break; - } + if (hasSettingsFieldChanged(field, newState, prevState)) { + changed = true; + break; } } @@ -413,13 +419,7 @@ export async function forceSyncSettingsToServer(): Promise { const updates: Record = {}; for (const field of SETTINGS_FIELDS_TO_SYNC) { - if (field === 'currentProjectId') { - updates[field] = appState.currentProject?.id ?? null; - } else if (field === 'terminalFontFamily') { - updates[field] = appState.terminalState.fontFamily; - } else { - updates[field] = appState[field as keyof typeof appState]; - } + updates[field] = getSettingsFieldValue(field, appState); } const setupState = useSetupStore.getState(); for (const field of SETUP_FIELDS_TO_SYNC) { diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index ec09be70..c911a80d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -8,7 +8,7 @@ import { useFileBrowser, setGlobalFileBrowser, } from '@/contexts/file-browser-context'; -import { useAppStore, getStoredTheme } from '@/store/app-store'; +import { useAppStore, getStoredTheme, type ThemeMode } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { useAuthStore } from '@/store/auth-store'; import { getElectronAPI, isElectron } from '@/lib/electron'; @@ -681,7 +681,7 @@ function RootLayoutContent() { upsertAndSetCurrentProject( autoOpenCandidate.path, autoOpenCandidate.name, - autoOpenCandidate.theme + autoOpenCandidate.theme as ThemeMode | undefined ); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 121fb8cd..a23c17c4 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -149,6 +149,31 @@ export function getStoredTheme(): ThemeMode | null { return null; } +/** + * Helper to get effective font value with validation + * Returns the font to use (project override -> global -> null for default) + * @param projectFont - The project-specific font override + * @param globalFont - The global font setting + * @param fontOptions - The list of valid font options for validation + */ +function getEffectiveFont( + projectFont: string | undefined, + globalFont: string | null, + fontOptions: readonly { value: string; label: string }[] +): string | null { + const isValidFont = (font: string | null | undefined): boolean => { + if (!font || font === DEFAULT_FONT_VALUE) return true; + return fontOptions.some((opt) => opt.value === font); + }; + + if (projectFont) { + if (!isValidFont(projectFont)) return null; // Fallback to default if font not in list + return projectFont === DEFAULT_FONT_VALUE ? null : projectFont; + } + if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list + return globalFont === DEFAULT_FONT_VALUE ? null : globalFont; +} + /** * Save theme to localStorage for immediate persistence * This is used as a fallback when server settings can't be loaded @@ -1415,7 +1440,7 @@ const initialState: AppState = { defaultFontSize: 14, defaultRunScript: '', screenReaderMode: false, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontFamily: DEFAULT_FONT_VALUE, scrollbackLines: 5000, lineHeight: 1.0, maxSessions: 100, @@ -1873,43 +1898,13 @@ export const useAppStore = create()((set, get) => ({ }, getEffectiveFontSans: () => { - const currentProject = get().currentProject; - // Return project override if set, otherwise global, otherwise null for default - // 'default' value means explicitly using default font, so return null for CSS - // Also validate that the font is in the available options list - const isValidFont = (font: string | null | undefined): boolean => { - if (!font || font === DEFAULT_FONT_VALUE) return true; - return UI_SANS_FONT_OPTIONS.some((opt) => opt.value === font); - }; - - if (currentProject?.fontFamilySans) { - const font = currentProject.fontFamilySans; - if (!isValidFont(font)) return null; // Fallback to default if font not in list - return font === DEFAULT_FONT_VALUE ? null : font; - } - const globalFont = get().fontFamilySans; - if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list - return globalFont === DEFAULT_FONT_VALUE ? null : globalFont; + const { currentProject, fontFamilySans } = get(); + return getEffectiveFont(currentProject?.fontFamilySans, fontFamilySans, UI_SANS_FONT_OPTIONS); }, getEffectiveFontMono: () => { - const currentProject = get().currentProject; - // Return project override if set, otherwise global, otherwise null for default - // 'default' value means explicitly using default font, so return null for CSS - // Also validate that the font is in the available options list - const isValidFont = (font: string | null | undefined): boolean => { - if (!font || font === DEFAULT_FONT_VALUE) return true; - return UI_MONO_FONT_OPTIONS.some((opt) => opt.value === font); - }; - - if (currentProject?.fontFamilyMono) { - const font = currentProject.fontFamilyMono; - if (!isValidFont(font)) return null; // Fallback to default if font not in list - return font === DEFAULT_FONT_VALUE ? null : font; - } - const globalFont = get().fontFamilyMono; - if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list - return globalFont === DEFAULT_FONT_VALUE ? null : globalFont; + const { currentProject, fontFamilyMono } = get(); + return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS); }, // Feature actions