mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Compare commits
24 Commits
2f883bad20
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2f277f63 | ||
|
|
ef3f8de33b | ||
|
|
d379bf412a | ||
|
|
cf35ca8650 | ||
|
|
4f1555f196 | ||
|
|
5aace0ce0f | ||
|
|
e439d8a632 | ||
|
|
b7c6b8bfc6 | ||
|
|
a60904bd51 | ||
|
|
d7c3337330 | ||
|
|
79236ba16e | ||
|
|
c848306e4c | ||
|
|
f0042312d0 | ||
|
|
e876d177b8 | ||
|
|
8caec15199 | ||
|
|
7fe9aacb09 | ||
|
|
f55c985634 | ||
|
|
38e8a4c4ea | ||
|
|
f3ce5ce8ab | ||
|
|
99de7813c9 | ||
|
|
2de3ae69d4 | ||
|
|
0b4e9573ed | ||
|
|
d7ad87bd1b | ||
|
|
615823652c |
@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
featureName: created.title || 'Untitled Feature',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
|
||||
await secureFs.mkdir(boardDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Use a fixed filename for the board background (overwrite previous)
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
|
||||
await secureFs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
|
||||
@@ -4597,21 +4597,54 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
planVersion,
|
||||
});
|
||||
|
||||
// Build revision prompt
|
||||
let revisionPrompt = `The user has requested revisions to the plan/specification.
|
||||
// Build revision prompt using customizable template
|
||||
const revisionPrompts = await getPromptCustomization(
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
|
||||
## Previous Plan (v${planVersion - 1})
|
||||
${hasEdits ? approvalResult.editedPlan : currentPlanContent}
|
||||
// Get task format example based on planning mode
|
||||
const taskFormatExample =
|
||||
planningMode === 'full'
|
||||
? `\`\`\`tasks
|
||||
## Phase 1: Foundation
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
|
||||
## User Feedback
|
||||
${approvalResult.feedback || 'Please revise the plan based on the edits above.'}
|
||||
## Phase 2: Core Implementation
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
- [ ] T004: [Description] | File: [path/to/file]
|
||||
\`\`\``
|
||||
: `\`\`\`tasks
|
||||
- [ ] T001: [Description] | File: [path/to/file]
|
||||
- [ ] T002: [Description] | File: [path/to/file]
|
||||
- [ ] T003: [Description] | File: [path/to/file]
|
||||
\`\`\``;
|
||||
|
||||
## Instructions
|
||||
Please regenerate the specification incorporating the user's feedback.
|
||||
Keep the same format with the \`\`\`tasks block for task definitions.
|
||||
After generating the revised spec, output:
|
||||
"[SPEC_GENERATED] Please review the revised specification above."
|
||||
`;
|
||||
let revisionPrompt = revisionPrompts.taskExecution.planRevisionTemplate;
|
||||
revisionPrompt = revisionPrompt.replace(
|
||||
/\{\{planVersion\}\}/g,
|
||||
String(planVersion - 1)
|
||||
);
|
||||
revisionPrompt = revisionPrompt.replace(
|
||||
/\{\{previousPlan\}\}/g,
|
||||
hasEdits
|
||||
? approvalResult.editedPlan || currentPlanContent
|
||||
: currentPlanContent
|
||||
);
|
||||
revisionPrompt = revisionPrompt.replace(
|
||||
/\{\{userFeedback\}\}/g,
|
||||
approvalResult.feedback ||
|
||||
'Please revise the plan based on the edits above.'
|
||||
);
|
||||
revisionPrompt = revisionPrompt.replace(
|
||||
/\{\{planningMode\}\}/g,
|
||||
planningMode
|
||||
);
|
||||
revisionPrompt = revisionPrompt.replace(
|
||||
/\{\{taskFormatExample\}\}/g,
|
||||
taskFormatExample
|
||||
);
|
||||
|
||||
// Update status to regenerating
|
||||
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
@@ -4663,6 +4696,26 @@ After generating the revised spec, output:
|
||||
const revisedTasks = parseTasksFromSpec(currentPlanContent);
|
||||
logger.info(`Revised plan has ${revisedTasks.length} tasks`);
|
||||
|
||||
// Warn if no tasks found in spec/full mode - this may cause fallback to single-agent
|
||||
if (
|
||||
revisedTasks.length === 0 &&
|
||||
(planningMode === 'spec' || planningMode === 'full')
|
||||
) {
|
||||
logger.warn(
|
||||
`WARNING: Revised plan in ${planningMode} mode has no tasks! ` +
|
||||
`This will cause fallback to single-agent execution. ` +
|
||||
`The AI may have omitted the required \`\`\`tasks block.`
|
||||
);
|
||||
this.emitAutoModeEvent('plan_revision_warning', {
|
||||
featureId,
|
||||
projectPath,
|
||||
branchName,
|
||||
planningMode,
|
||||
warning:
|
||||
'Revised plan missing tasks block - will use single-agent execution',
|
||||
});
|
||||
}
|
||||
|
||||
// Update planSpec with revised content
|
||||
await this.updateFeaturePlanSpec(projectPath, featureId, {
|
||||
status: 'generated',
|
||||
|
||||
@@ -169,9 +169,10 @@ export class EventHookService {
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: payload.featureName,
|
||||
featureName: featureName || payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||
error: payload.error || payload.message,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { IconPicker } from './icon-picker';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EditProjectDialogProps {
|
||||
project: Project;
|
||||
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error(
|
||||
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
// Validate file size (max 5MB for icons - allows animated GIFs)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toast.error(
|
||||
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
file.type,
|
||||
project.path
|
||||
);
|
||||
|
||||
if (result.success && result.path) {
|
||||
setCustomIconPath(result.path);
|
||||
// Clear the Lucide icon when custom icon is set
|
||||
setIcon(null);
|
||||
toast.success('Icon uploaded successfully');
|
||||
} else {
|
||||
toast.error('Failed to upload icon');
|
||||
}
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error('Failed to read file');
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
toast.error('Failed to upload icon');
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG, GIF or WebP. Max 2MB.
|
||||
PNG, JPG, GIF or WebP. Max 5MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ThemeButtonProps {
|
||||
/** Handler for pointer leave events (used to clear preview) */
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler for click events (used to select theme) */
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +77,7 @@ const ThemeButton = memo(function ThemeButton({
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={onClick}
|
||||
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
|
||||
isSelected={selectedTheme === option.value}
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
onClick={() => onSelect(option.value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -193,7 +197,6 @@ export function ProjectContextMenu({
|
||||
const {
|
||||
moveProjectToTrash,
|
||||
theme: globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
} = useAppStore();
|
||||
@@ -316,13 +319,24 @@ export function ProjectContextMenu({
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
|
||||
// Clear any pending close timeout to prevent race conditions
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Close menu first
|
||||
setShowThemeSubmenu(false);
|
||||
onClose();
|
||||
|
||||
// Then apply theme changes
|
||||
setPreviewTheme(null);
|
||||
const isUsingGlobal = value === USE_GLOBAL_THEME;
|
||||
setTheme(isUsingGlobal ? globalTheme : value);
|
||||
// Only set project theme - don't change global theme
|
||||
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
|
||||
setProjectTheme(project.id, isUsingGlobal ? null : value);
|
||||
setShowThemeSubmenu(false);
|
||||
},
|
||||
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
|
||||
[onClose, project.id, setPreviewTheme, setProjectTheme]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
@@ -426,9 +440,13 @@ export function ProjectContextMenu({
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
<button
|
||||
type="button"
|
||||
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
||||
onPointerLeave={handlePreviewLeave}
|
||||
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleThemeSelect(USE_GLOBAL_THEME);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, LucideIcon } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn, sanitizeForTestId } from '@/lib/utils';
|
||||
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ProjectSwitcherItemProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
|
||||
const hotkeyLabel =
|
||||
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
|
||||
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
|
||||
};
|
||||
|
||||
const IconComponent = getIconComponent();
|
||||
const hasCustomIcon = !!project.customIconPath;
|
||||
const hasCustomIcon = !!project.customIconPath && !imageError;
|
||||
|
||||
// Combine project.id with sanitized name for uniqueness and readability
|
||||
// Format: project-switcher-{id}-{sanitizedName}
|
||||
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
|
||||
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
|
||||
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
|
||||
)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<IconComponent
|
||||
|
||||
@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
|
||||
|
||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||
|
||||
const {
|
||||
globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
handlePreviewEnter,
|
||||
handlePreviewLeave,
|
||||
} = useProjectTheme();
|
||||
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||
useProjectTheme();
|
||||
|
||||
if (!sidebarOpen || projects.length === 0) {
|
||||
return null;
|
||||
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
|
||||
onValueChange={(value) => {
|
||||
if (currentProject) {
|
||||
setPreviewTheme(null);
|
||||
if (value !== '') {
|
||||
setTheme(value as ThemeMode);
|
||||
} else {
|
||||
setTheme(globalTheme);
|
||||
}
|
||||
// Only set project theme - don't change global theme
|
||||
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
|
||||
setProjectTheme(
|
||||
currentProject.id,
|
||||
value === '' ? null : (value as ThemeMode)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
@@ -97,6 +100,17 @@ export function SidebarNavigation({
|
||||
return !!currentProject;
|
||||
});
|
||||
|
||||
// Get the icon component for the current project
|
||||
const getProjectIcon = (): LucideIcon => {
|
||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
|
||||
}
|
||||
return Folder;
|
||||
};
|
||||
|
||||
const ProjectIcon = getProjectIcon();
|
||||
const hasCustomIcon = !!currentProject?.customIconPath;
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
@@ -106,6 +120,27 @@ export function SidebarNavigation({
|
||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{/* Project name display for classic/discord mode */}
|
||||
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
|
||||
alt={currentProject.name}
|
||||
className="w-5 h-5 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-px bg-border/40 mx-1 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation sections */}
|
||||
{visibleSections.map((section, sectionIdx) => {
|
||||
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
||||
|
||||
@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
|
||||
}: ThemeMenuItemProps) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
className="text-xs py-1.5"
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
className="text-xs py-1.5"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</div>
|
||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,10 +68,10 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
// Validate file size (max 5MB for icons - allows animated GIFs)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('File too large', {
|
||||
description: 'Please upload an image smaller than 2MB.',
|
||||
description: 'Please upload an image smaller than 5MB.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG, GIF or WebP. Max 2MB.
|
||||
PNG, JPG, GIF or WebP. Max 5MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automak
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function EventHistoryView() {
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
@@ -85,16 +86,18 @@ export function EventHistoryView() {
|
||||
const failCount = hookResults.filter((r) => !r.success).length;
|
||||
|
||||
if (hooksTriggered === 0) {
|
||||
alert('No matching hooks found for this event trigger.');
|
||||
toast.info('No matching hooks found for this event trigger.');
|
||||
} else if (failCount === 0) {
|
||||
alert(`Successfully ran ${successCount} hook(s).`);
|
||||
toast.success(`Successfully ran ${successCount} hook(s).`);
|
||||
} else {
|
||||
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
|
||||
toast.warning(
|
||||
`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to replay event:', error);
|
||||
alert('Failed to replay event. Check console for details.');
|
||||
toast.error('Failed to replay event. Check console for details.');
|
||||
} finally {
|
||||
setReplayingEvent(null);
|
||||
}
|
||||
|
||||
47
apps/ui/src/electron/constants.ts
Normal file
47
apps/ui/src/electron/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Electron main process constants
|
||||
*
|
||||
* Centralized configuration for window sizing, ports, and file names.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Window sizing constants for kanban layout
|
||||
// ============================================
|
||||
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
|
||||
export const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
|
||||
export const MIN_HEIGHT = 500; // Reduced to allow more flexibility
|
||||
export const DEFAULT_WIDTH = 1600;
|
||||
export const DEFAULT_HEIGHT = 950;
|
||||
|
||||
// ============================================
|
||||
// Port defaults
|
||||
// ============================================
|
||||
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
|
||||
// When launched via root init.mjs we pass:
|
||||
// - PORT (backend)
|
||||
// - TEST_PORT (vite dev server / static)
|
||||
// Guard against NaN from non-numeric environment variables
|
||||
const parsedServerPort = Number.parseInt(process.env.PORT ?? '', 10);
|
||||
const parsedStaticPort = Number.parseInt(process.env.TEST_PORT ?? '', 10);
|
||||
export const DEFAULT_SERVER_PORT = Number.isFinite(parsedServerPort) ? parsedServerPort : 3008;
|
||||
export const DEFAULT_STATIC_PORT = Number.isFinite(parsedStaticPort) ? parsedStaticPort : 3007;
|
||||
|
||||
// ============================================
|
||||
// File names for userData storage
|
||||
// ============================================
|
||||
export const API_KEY_FILENAME = '.api-key';
|
||||
export const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
|
||||
|
||||
// ============================================
|
||||
// Window bounds interface
|
||||
// ============================================
|
||||
// Matches @automaker/types WindowBounds
|
||||
export interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
32
apps/ui/src/electron/index.ts
Normal file
32
apps/ui/src/electron/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Electron main process modules
|
||||
*
|
||||
* Re-exports for convenient importing.
|
||||
*/
|
||||
|
||||
// Constants and types
|
||||
export * from './constants';
|
||||
export { state } from './state';
|
||||
|
||||
// Utilities
|
||||
export { isPortAvailable, findAvailablePort } from './utils/port-manager';
|
||||
export { getIconPath } from './utils/icon-manager';
|
||||
|
||||
// Security
|
||||
export { ensureApiKey, getApiKey } from './security/api-key-manager';
|
||||
|
||||
// Windows
|
||||
export {
|
||||
loadWindowBounds,
|
||||
saveWindowBounds,
|
||||
validateBounds,
|
||||
scheduleSaveWindowBounds,
|
||||
} from './windows/window-bounds';
|
||||
export { createWindow } from './windows/main-window';
|
||||
|
||||
// Server
|
||||
export { startStaticServer, stopStaticServer } from './server/static-server';
|
||||
export { startServer, waitForServer, stopServer } from './server/backend-server';
|
||||
|
||||
// IPC
|
||||
export { IPC_CHANNELS, registerAllHandlers } from './ipc';
|
||||
37
apps/ui/src/electron/ipc/app-handlers.ts
Normal file
37
apps/ui/src/electron/ipc/app-handlers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* App IPC handlers
|
||||
*
|
||||
* Handles app-related operations like getting paths, version info, and quitting.
|
||||
*/
|
||||
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
|
||||
const logger = createLogger('AppHandlers');
|
||||
|
||||
/**
|
||||
* Register app IPC handlers
|
||||
*/
|
||||
export function registerAppHandlers(): void {
|
||||
// Get app path
|
||||
ipcMain.handle(IPC_CHANNELS.APP.GET_PATH, async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
// Get app version
|
||||
ipcMain.handle(IPC_CHANNELS.APP.GET_VERSION, async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// Check if app is packaged
|
||||
ipcMain.handle(IPC_CHANNELS.APP.IS_PACKAGED, async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Quit the application
|
||||
ipcMain.handle(IPC_CHANNELS.APP.QUIT, () => {
|
||||
logger.info('Quitting application via IPC request');
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
34
apps/ui/src/electron/ipc/auth-handlers.ts
Normal file
34
apps/ui/src/electron/ipc/auth-handlers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Auth IPC handlers
|
||||
*
|
||||
* Handles authentication-related operations.
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
import { state } from '../state';
|
||||
|
||||
/**
|
||||
* Register auth IPC handlers
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
// Get API key for authentication
|
||||
// Returns null in external server mode to trigger session-based auth
|
||||
// Only returns API key to the main window to prevent leaking to untrusted senders
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH.GET_API_KEY, (event) => {
|
||||
// Validate sender is the main window
|
||||
if (event.sender !== state.mainWindow?.webContents) {
|
||||
return null;
|
||||
}
|
||||
if (state.isExternalServerMode) {
|
||||
return null;
|
||||
}
|
||||
return state.apiKey;
|
||||
});
|
||||
|
||||
// Check if running in external server mode (Docker API)
|
||||
// Used by renderer to determine auth flow
|
||||
ipcMain.handle(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE, () => {
|
||||
return state.isExternalServerMode;
|
||||
});
|
||||
}
|
||||
36
apps/ui/src/electron/ipc/channels.ts
Normal file
36
apps/ui/src/electron/ipc/channels.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* IPC channel constants
|
||||
*
|
||||
* Single source of truth for all IPC channel names.
|
||||
* Used by both main process handlers and preload script.
|
||||
*/
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
DIALOG: {
|
||||
OPEN_DIRECTORY: 'dialog:openDirectory',
|
||||
OPEN_FILE: 'dialog:openFile',
|
||||
SAVE_FILE: 'dialog:saveFile',
|
||||
},
|
||||
SHELL: {
|
||||
OPEN_EXTERNAL: 'shell:openExternal',
|
||||
OPEN_PATH: 'shell:openPath',
|
||||
OPEN_IN_EDITOR: 'shell:openInEditor',
|
||||
},
|
||||
APP: {
|
||||
GET_PATH: 'app:getPath',
|
||||
GET_VERSION: 'app:getVersion',
|
||||
IS_PACKAGED: 'app:isPackaged',
|
||||
QUIT: 'app:quit',
|
||||
},
|
||||
AUTH: {
|
||||
GET_API_KEY: 'auth:getApiKey',
|
||||
IS_EXTERNAL_SERVER_MODE: 'auth:isExternalServerMode',
|
||||
},
|
||||
WINDOW: {
|
||||
UPDATE_MIN_WIDTH: 'window:updateMinWidth',
|
||||
},
|
||||
SERVER: {
|
||||
GET_URL: 'server:getUrl',
|
||||
},
|
||||
PING: 'ping',
|
||||
} as const;
|
||||
72
apps/ui/src/electron/ipc/dialog-handlers.ts
Normal file
72
apps/ui/src/electron/ipc/dialog-handlers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Dialog IPC handlers
|
||||
*
|
||||
* Handles native file dialog operations.
|
||||
*/
|
||||
|
||||
import { ipcMain, dialog } from 'electron';
|
||||
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
import { state } from '../state';
|
||||
|
||||
/**
|
||||
* Register dialog IPC handlers
|
||||
*/
|
||||
export function registerDialogHandlers(): void {
|
||||
// Open directory dialog
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY, async () => {
|
||||
if (!state.mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
const result = await dialog.showOpenDialog(state.mainWindow, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
|
||||
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
if (!isPathAllowed(selectedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
|
||||
: 'The selected directory is not allowed.';
|
||||
|
||||
dialog.showErrorBox('Directory Not Allowed', errorMessage);
|
||||
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Open file dialog
|
||||
// Filter properties to maintain file-only intent and prevent renderer from requesting directories
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIALOG.OPEN_FILE,
|
||||
async (_, options: Record<string, unknown> = {}) => {
|
||||
if (!state.mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
// Ensure openFile is always present and filter out directory-related properties
|
||||
const inputProperties = (options.properties as string[]) ?? [];
|
||||
const properties = ['openFile', ...inputProperties].filter(
|
||||
(p) => p !== 'openDirectory' && p !== 'createDirectory'
|
||||
);
|
||||
const result = await dialog.showOpenDialog(state.mainWindow, {
|
||||
...options,
|
||||
properties: properties as Electron.OpenDialogOptions['properties'],
|
||||
});
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// Save file dialog
|
||||
ipcMain.handle(IPC_CHANNELS.DIALOG.SAVE_FILE, async (_, options = {}) => {
|
||||
if (!state.mainWindow) {
|
||||
return { canceled: true, filePath: undefined };
|
||||
}
|
||||
const result = await dialog.showSaveDialog(state.mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
26
apps/ui/src/electron/ipc/index.ts
Normal file
26
apps/ui/src/electron/ipc/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* IPC handlers aggregator
|
||||
*
|
||||
* Registers all IPC handlers in one place.
|
||||
*/
|
||||
|
||||
import { registerDialogHandlers } from './dialog-handlers';
|
||||
import { registerShellHandlers } from './shell-handlers';
|
||||
import { registerAppHandlers } from './app-handlers';
|
||||
import { registerAuthHandlers } from './auth-handlers';
|
||||
import { registerWindowHandlers } from './window-handlers';
|
||||
import { registerServerHandlers } from './server-handlers';
|
||||
|
||||
export { IPC_CHANNELS } from './channels';
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerAllHandlers(): void {
|
||||
registerDialogHandlers();
|
||||
registerShellHandlers();
|
||||
registerAppHandlers();
|
||||
registerAuthHandlers();
|
||||
registerWindowHandlers();
|
||||
registerServerHandlers();
|
||||
}
|
||||
24
apps/ui/src/electron/ipc/server-handlers.ts
Normal file
24
apps/ui/src/electron/ipc/server-handlers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Server IPC handlers
|
||||
*
|
||||
* Handles server-related operations.
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
import { state } from '../state';
|
||||
|
||||
/**
|
||||
* Register server IPC handlers
|
||||
*/
|
||||
export function registerServerHandlers(): void {
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle(IPC_CHANNELS.SERVER.GET_URL, async () => {
|
||||
return `http://localhost:${state.serverPort}`;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle(IPC_CHANNELS.PING, async () => {
|
||||
return 'pong';
|
||||
});
|
||||
}
|
||||
61
apps/ui/src/electron/ipc/shell-handlers.ts
Normal file
61
apps/ui/src/electron/ipc/shell-handlers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shell IPC handlers
|
||||
*
|
||||
* Handles shell operations like opening external links and files.
|
||||
*/
|
||||
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
|
||||
/**
|
||||
* Register shell IPC handlers
|
||||
*/
|
||||
export function registerShellHandlers(): void {
|
||||
// Open external URL
|
||||
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Open file path
|
||||
ipcMain.handle(IPC_CHANNELS.SHELL.OPEN_PATH, async (_, filePath: string) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Open file in editor (VS Code, etc.) with optional line/column
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SHELL.OPEN_IN_EDITOR,
|
||||
async (_, filePath: string, line?: number, column?: number) => {
|
||||
try {
|
||||
// Build VS Code URL scheme: vscode://file/path:line:column
|
||||
// This works on all platforms where VS Code is installed
|
||||
// URL encode the path to handle special characters (spaces, brackets, etc.)
|
||||
// Handle both Unix (/) and Windows (\) path separators
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const segments = normalizedPath.split('/').map(encodeURIComponent);
|
||||
const encodedPath = segments.join('/');
|
||||
// VS Code URL format requires a leading slash after 'file'
|
||||
let url = `vscode://file/${encodedPath}`;
|
||||
if (line !== undefined && line > 0) {
|
||||
url += `:${line}`;
|
||||
if (column !== undefined && column > 0) {
|
||||
url += `:${column}`;
|
||||
}
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
24
apps/ui/src/electron/ipc/window-handlers.ts
Normal file
24
apps/ui/src/electron/ipc/window-handlers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Window IPC handlers
|
||||
*
|
||||
* Handles window management operations.
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from './channels';
|
||||
import { MIN_WIDTH_COLLAPSED, MIN_HEIGHT } from '../constants';
|
||||
import { state } from '../state';
|
||||
|
||||
/**
|
||||
* Register window IPC handlers
|
||||
*/
|
||||
export function registerWindowHandlers(): void {
|
||||
// Update minimum width based on sidebar state
|
||||
// Now uses a fixed small minimum since horizontal scrolling handles overflow
|
||||
ipcMain.handle(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, (_, _sidebarExpanded: boolean) => {
|
||||
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
|
||||
|
||||
// Always use the smaller minimum width - horizontal scrolling handles any overflow
|
||||
state.mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
|
||||
});
|
||||
}
|
||||
58
apps/ui/src/electron/security/api-key-manager.ts
Normal file
58
apps/ui/src/electron/security/api-key-manager.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* API key management
|
||||
*
|
||||
* Handles generation, storage, and retrieval of the API key for CSRF protection.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
electronUserDataExists,
|
||||
electronUserDataReadFileSync,
|
||||
electronUserDataWriteFileSync,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { API_KEY_FILENAME } from '../constants';
|
||||
import { state } from '../state';
|
||||
|
||||
const logger = createLogger('ApiKeyManager');
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
export function ensureApiKey(): string {
|
||||
try {
|
||||
if (electronUserDataExists(API_KEY_FILENAME)) {
|
||||
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
|
||||
if (key) {
|
||||
state.apiKey = key;
|
||||
logger.info('Loaded existing API key');
|
||||
return state.apiKey;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error reading API key:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
state.apiKey = crypto.randomUUID();
|
||||
try {
|
||||
electronUserDataWriteFileSync(API_KEY_FILENAME, state.apiKey, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
logger.info('Generated new API key');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save API key:', error);
|
||||
}
|
||||
return state.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current API key
|
||||
*/
|
||||
export function getApiKey(): string | null {
|
||||
return state.apiKey;
|
||||
}
|
||||
230
apps/ui/src/electron/server/backend-server.ts
Normal file
230
apps/ui/src/electron/server/backend-server.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Backend server management
|
||||
*
|
||||
* Handles starting, stopping, and monitoring the Express backend server.
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
findNodeExecutable,
|
||||
buildEnhancedPath,
|
||||
electronAppExists,
|
||||
systemPathExists,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { state } from '../state';
|
||||
|
||||
const logger = createLogger('BackendServer');
|
||||
const serverLogger = createLogger('Server');
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
export async function startServer(): Promise<void> {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
const nodeResult = findNodeExecutable({
|
||||
skipSearch: isDev,
|
||||
logger: (msg: string) => logger.info(msg),
|
||||
});
|
||||
const command = nodeResult.nodePath;
|
||||
|
||||
// Validate that the found Node executable actually exists
|
||||
// systemPathExists is used because node-finder returns system paths
|
||||
if (command !== 'node') {
|
||||
let exists: boolean;
|
||||
try {
|
||||
exists = systemPathExists(command);
|
||||
} catch (error) {
|
||||
const originalError = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
|
||||
);
|
||||
}
|
||||
if (!exists) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
}
|
||||
}
|
||||
|
||||
let args: string[];
|
||||
let serverPath: string;
|
||||
|
||||
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
|
||||
if (isDev) {
|
||||
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||
|
||||
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||
|
||||
let tsxCliPath: string;
|
||||
// Check for tsx in app bundle paths, fallback to require.resolve
|
||||
const serverTsxPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
const rootTsxPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
|
||||
try {
|
||||
if (electronAppExists(serverTsxPath)) {
|
||||
tsxCliPath = serverTsxPath;
|
||||
} else if (electronAppExists(rootTsxPath)) {
|
||||
tsxCliPath = rootTsxPath;
|
||||
} else {
|
||||
// Fallback to require.resolve
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// electronAppExists threw or require.resolve failed
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
|
||||
args = [tsxCliPath, 'watch', serverPath];
|
||||
} else {
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
if (!electronAppExists(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const serverNodeModules = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Server root directory - where .env file is located
|
||||
// In dev: apps/server (not apps/server/src)
|
||||
// In production: resources/server
|
||||
const serverRoot = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server')
|
||||
: path.join(__dirname, '../../server');
|
||||
|
||||
// IMPORTANT: Use shared data directory (not Electron's user data directory)
|
||||
// This ensures Electron and web mode share the same settings/projects
|
||||
// In dev: project root/data (navigate from __dirname which is apps/ui/dist-electron)
|
||||
// In production: same as Electron user data (for app isolation)
|
||||
const dataDir = app.isPackaged
|
||||
? app.getPath('userData')
|
||||
: path.join(__dirname, '../../..', 'data');
|
||||
logger.info(
|
||||
`[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
|
||||
);
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
logger.info('Enhanced PATH with Node directory:', path.dirname(command));
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: enhancedPath,
|
||||
PORT: state.serverPort.toString(),
|
||||
DATA_DIR: dataDir,
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Pass API key to server for CSRF protection
|
||||
AUTOMAKER_API_KEY: state.apiKey!,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
...(process.env.ALLOWED_ROOT_DIRECTORY && {
|
||||
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY,
|
||||
}),
|
||||
};
|
||||
|
||||
logger.info('Server will use port', state.serverPort);
|
||||
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
|
||||
|
||||
logger.info('Starting backend server...');
|
||||
logger.info('Server path:', serverPath);
|
||||
logger.info('Server root (cwd):', serverRoot);
|
||||
logger.info('NODE_PATH:', serverNodeModules);
|
||||
|
||||
state.serverProcess = spawn(command, args, {
|
||||
cwd: serverRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
state.serverProcess.stdout?.on('data', (data) => {
|
||||
serverLogger.info(data.toString().trim());
|
||||
});
|
||||
|
||||
state.serverProcess.stderr?.on('data', (data) => {
|
||||
serverLogger.error(data.toString().trim());
|
||||
});
|
||||
|
||||
state.serverProcess.on('close', (code) => {
|
||||
serverLogger.info('Process exited with code', code);
|
||||
state.serverProcess = null;
|
||||
});
|
||||
|
||||
state.serverProcess.on('error', (err) => {
|
||||
serverLogger.error('Failed to start server process:', err);
|
||||
state.serverProcess = null;
|
||||
});
|
||||
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be available
|
||||
*/
|
||||
export async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${state.serverPort}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
});
|
||||
logger.info('Server is ready');
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Server failed to start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the backend server if running
|
||||
*/
|
||||
export function stopServer(): void {
|
||||
if (state.serverProcess && state.serverProcess.pid) {
|
||||
logger.info('Stopping server...');
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
// Windows: use taskkill with /t to kill entire process tree
|
||||
// This prevents orphaned node processes when closing the app
|
||||
// Using execSync to ensure process is killed before app exits
|
||||
execSync(`taskkill /f /t /pid ${state.serverProcess.pid}`, { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill server process:', (error as Error).message);
|
||||
}
|
||||
} else {
|
||||
state.serverProcess.kill('SIGTERM');
|
||||
}
|
||||
state.serverProcess = null;
|
||||
}
|
||||
}
|
||||
101
apps/ui/src/electron/server/static-server.ts
Normal file
101
apps/ui/src/electron/server/static-server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Static file server for production builds
|
||||
*
|
||||
* Serves the built frontend files in production mode.
|
||||
* Uses centralized electronApp methods for serving static files from app bundle.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { electronAppExists, electronAppStat, electronAppReadFile } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { state } from '../state';
|
||||
|
||||
const logger = createLogger('StaticServer');
|
||||
|
||||
/**
|
||||
* MIME type mapping for static files
|
||||
*/
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
* Uses centralized electronApp methods for serving static files from app bundle.
|
||||
*/
|
||||
export async function startStaticServer(): Promise<void> {
|
||||
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
|
||||
state.staticServer = http.createServer((request, response) => {
|
||||
let filePath = path.join(staticPath, request.url?.split('?')[0] || '/');
|
||||
|
||||
if (filePath.endsWith('/')) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
} else if (!path.extname(filePath)) {
|
||||
// For client-side routing, serve index.html for paths without extensions
|
||||
const possibleFile = filePath + '.html';
|
||||
try {
|
||||
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (electronAppExists(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
} catch {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
}
|
||||
|
||||
electronAppStat(filePath, (err, stats) => {
|
||||
if (err || !stats?.isFile()) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
|
||||
electronAppReadFile(filePath, (error, content) => {
|
||||
if (error || !content) {
|
||||
response.writeHead(500);
|
||||
response.end('Server Error');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': CONTENT_TYPES[ext] || 'application/octet-stream',
|
||||
});
|
||||
response.end(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
state.staticServer!.listen(state.staticPort, () => {
|
||||
logger.info('Static server running at http://localhost:' + state.staticPort);
|
||||
resolve();
|
||||
});
|
||||
state.staticServer!.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the static server if running
|
||||
*/
|
||||
export function stopStaticServer(): void {
|
||||
if (state.staticServer) {
|
||||
logger.info('Stopping static server...');
|
||||
state.staticServer.close();
|
||||
state.staticServer = null;
|
||||
}
|
||||
}
|
||||
33
apps/ui/src/electron/state.ts
Normal file
33
apps/ui/src/electron/state.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Electron main process shared state
|
||||
*
|
||||
* Centralized state container to avoid circular dependencies.
|
||||
* All modules access shared state through this object.
|
||||
*/
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { Server } from 'http';
|
||||
import { DEFAULT_SERVER_PORT, DEFAULT_STATIC_PORT } from './constants';
|
||||
|
||||
export interface ElectronState {
|
||||
mainWindow: BrowserWindow | null;
|
||||
serverProcess: ChildProcess | null;
|
||||
staticServer: Server | null;
|
||||
serverPort: number;
|
||||
staticPort: number;
|
||||
apiKey: string | null;
|
||||
isExternalServerMode: boolean;
|
||||
saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export const state: ElectronState = {
|
||||
mainWindow: null,
|
||||
serverProcess: null,
|
||||
staticServer: null,
|
||||
serverPort: DEFAULT_SERVER_PORT,
|
||||
staticPort: DEFAULT_STATIC_PORT,
|
||||
apiKey: null,
|
||||
isExternalServerMode: false,
|
||||
saveWindowBoundsTimeout: null,
|
||||
};
|
||||
46
apps/ui/src/electron/utils/icon-manager.ts
Normal file
46
apps/ui/src/electron/utils/icon-manager.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Icon management utilities
|
||||
*
|
||||
* Functions for getting the application icon path.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { electronAppExists } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('IconManager');
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
* Uses centralized electronApp methods for path validation.
|
||||
*/
|
||||
export function getIconPath(): string | null {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
let iconFile: string;
|
||||
if (process.platform === 'win32') {
|
||||
iconFile = 'icon.ico';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconFile = 'logo_larger.png';
|
||||
} else {
|
||||
iconFile = 'logo_larger.png';
|
||||
}
|
||||
|
||||
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
|
||||
const iconPath = isDev
|
||||
? path.join(__dirname, '../public', iconFile)
|
||||
: path.join(__dirname, '../dist/public', iconFile);
|
||||
|
||||
try {
|
||||
if (!electronAppExists(iconPath)) {
|
||||
logger.warn('Icon not found at:', iconPath);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Icon check failed:', iconPath, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return iconPath;
|
||||
}
|
||||
42
apps/ui/src/electron/utils/port-manager.ts
Normal file
42
apps/ui/src/electron/utils/port-manager.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Port management utilities
|
||||
*
|
||||
* Functions for checking port availability and finding open ports.
|
||||
* No Electron dependencies - pure utility module.
|
||||
*/
|
||||
|
||||
import net from 'net';
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
*/
|
||||
export function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
// Use Node's default binding semantics (matches most dev servers)
|
||||
// This avoids false-positives when a port is taken on IPv6/dual-stack.
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the preferred port
|
||||
* Tries up to 100 ports in sequence
|
||||
*/
|
||||
export async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
for (let offset = 0; offset < 100; offset++) {
|
||||
const port = preferredPort + offset;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find an available port starting from ${preferredPort}`);
|
||||
}
|
||||
116
apps/ui/src/electron/windows/main-window.ts
Normal file
116
apps/ui/src/electron/windows/main-window.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Main window creation and lifecycle
|
||||
*
|
||||
* Handles creating the main BrowserWindow and its event handlers.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { app, BrowserWindow, shell } from 'electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { MIN_WIDTH_COLLAPSED, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT } from '../constants';
|
||||
import { state } from '../state';
|
||||
import { getIconPath } from '../utils/icon-manager';
|
||||
import {
|
||||
loadWindowBounds,
|
||||
saveWindowBounds,
|
||||
validateBounds,
|
||||
scheduleSaveWindowBounds,
|
||||
} from './window-bounds';
|
||||
|
||||
const logger = createLogger('MainWindow');
|
||||
|
||||
// Development environment
|
||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
/**
|
||||
* Create the main window
|
||||
*/
|
||||
export function createWindow(): void {
|
||||
const isDev = !app.isPackaged;
|
||||
const iconPath = getIconPath();
|
||||
|
||||
// Load and validate saved window bounds
|
||||
const savedBounds = loadWindowBounds();
|
||||
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||
x: validBounds?.x,
|
||||
y: validBounds?.y,
|
||||
minWidth: MIN_WIDTH_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
|
||||
minHeight: MIN_HEIGHT,
|
||||
webPreferences: {
|
||||
// __dirname is apps/ui/dist-electron (Vite bundles all into single file)
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
// titleBarStyle is macOS-only; use hiddenInset for native look on macOS
|
||||
...(process.platform === 'darwin' && { titleBarStyle: 'hiddenInset' as const }),
|
||||
backgroundColor: '#0a0a0a',
|
||||
};
|
||||
|
||||
if (iconPath) {
|
||||
windowOptions.icon = iconPath;
|
||||
}
|
||||
|
||||
state.mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Restore maximized state if previously maximized
|
||||
if (validBounds?.isMaximized) {
|
||||
state.mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Load Vite dev server in development or static server in production
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
state.mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else if (isDev) {
|
||||
// Fallback for dev without Vite server URL
|
||||
state.mainWindow.loadURL(`http://localhost:${state.staticPort}`);
|
||||
} else {
|
||||
state.mainWindow.loadURL(`http://localhost:${state.staticPort}`);
|
||||
}
|
||||
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
|
||||
state.mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Save window bounds on close, resize, and move
|
||||
state.mainWindow.on('close', () => {
|
||||
// Save immediately before closing (not debounced)
|
||||
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
||||
const isMaximized = state.mainWindow.isMaximized();
|
||||
const bounds = isMaximized
|
||||
? state.mainWindow.getNormalBounds()
|
||||
: state.mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
state.mainWindow.on('closed', () => {
|
||||
state.mainWindow = null;
|
||||
});
|
||||
|
||||
state.mainWindow.on('resized', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
state.mainWindow.on('moved', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
state.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
logger.info('Main window created');
|
||||
}
|
||||
130
apps/ui/src/electron/windows/window-bounds.ts
Normal file
130
apps/ui/src/electron/windows/window-bounds.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Window bounds management
|
||||
*
|
||||
* Functions for loading, saving, and validating window bounds.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
|
||||
import { screen } from 'electron';
|
||||
import {
|
||||
electronUserDataExists,
|
||||
electronUserDataReadFileSync,
|
||||
electronUserDataWriteFileSync,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
WindowBounds,
|
||||
WINDOW_BOUNDS_FILENAME,
|
||||
MIN_WIDTH_COLLAPSED,
|
||||
MIN_HEIGHT,
|
||||
} from '../constants';
|
||||
import { state } from '../state';
|
||||
|
||||
const logger = createLogger('WindowBounds');
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
export function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
|
||||
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
typeof bounds.x === 'number' &&
|
||||
typeof bounds.y === 'number' &&
|
||||
typeof bounds.width === 'number' &&
|
||||
typeof bounds.height === 'number'
|
||||
) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load window bounds:', (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
export function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
|
||||
logger.info('Window bounds saved');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to save window bounds:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced save of window bounds (500ms delay)
|
||||
*/
|
||||
export function scheduleSaveWindowBounds(): void {
|
||||
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
|
||||
|
||||
if (state.saveWindowBoundsTimeout) {
|
||||
clearTimeout(state.saveWindowBoundsTimeout);
|
||||
}
|
||||
|
||||
state.saveWindowBoundsTimeout = setTimeout(() => {
|
||||
if (!state.mainWindow || state.mainWindow.isDestroyed()) return;
|
||||
|
||||
const isMaximized = state.mainWindow.isMaximized();
|
||||
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||
const bounds = isMaximized ? state.mainWindow.getNormalBounds() : state.mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that window bounds are visible on at least one display
|
||||
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||
*/
|
||||
export function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
const displays = screen.getAllDisplays();
|
||||
|
||||
// Check if window center is visible on any display
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
|
||||
let isVisible = false;
|
||||
for (const display of displays) {
|
||||
const { x, y, width, height } = display.workArea;
|
||||
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
// Window is off-screen, reset to primary display
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { x, y, width, height } = primaryDisplay.workArea;
|
||||
|
||||
return {
|
||||
x: x + Math.floor((width - bounds.width) / 2),
|
||||
y: y + Math.floor((height - bounds.height) / 2),
|
||||
width: Math.min(bounds.width, width),
|
||||
height: Math.min(bounds.height, height),
|
||||
isMaximized: bounds.isMaximized,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure minimum dimensions
|
||||
return {
|
||||
...bounds,
|
||||
width: Math.max(bounds.width, MIN_WIDTH_COLLAPSED),
|
||||
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
@@ -946,7 +946,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
@@ -976,7 +976,7 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
// Ensure API key is initialized before making request
|
||||
await waitForApiKeyInit();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
/**
|
||||
* Electron main process (TypeScript)
|
||||
* Electron main process entry point
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
* Handles app lifecycle, initialization, and coordination of modular components.
|
||||
*
|
||||
* Architecture:
|
||||
* - electron/constants.ts - Window sizing, port defaults, filenames
|
||||
* - electron/state.ts - Shared state container
|
||||
* - electron/utils/ - Port and icon utilities
|
||||
* - electron/security/ - API key management
|
||||
* - electron/windows/ - Window bounds and main window creation
|
||||
* - electron/server/ - Backend and static server management
|
||||
* - electron/ipc/ - IPC handlers (dialog, shell, app, auth, window, server)
|
||||
*
|
||||
* SECURITY: All file system access uses centralized methods from @automaker/platform.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import http, { Server } from 'http';
|
||||
import net from 'net';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import {
|
||||
findNodeExecutable,
|
||||
buildEnhancedPath,
|
||||
initAllowedPaths,
|
||||
isPathAllowed,
|
||||
getAllowedRootDirectory,
|
||||
// Electron userData operations
|
||||
setElectronUserDataPath,
|
||||
electronUserDataReadFileSync,
|
||||
electronUserDataWriteFileSync,
|
||||
electronUserDataExists,
|
||||
// Electron app bundle operations
|
||||
setElectronAppPaths,
|
||||
electronAppExists,
|
||||
electronAppStat,
|
||||
electronAppReadFile,
|
||||
// System path operations
|
||||
systemPathExists,
|
||||
initAllowedPaths,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { DEFAULT_SERVER_PORT, DEFAULT_STATIC_PORT } from './electron/constants';
|
||||
import { state } from './electron/state';
|
||||
import { findAvailablePort } from './electron/utils/port-manager';
|
||||
import { getIconPath } from './electron/utils/icon-manager';
|
||||
import { ensureApiKey } from './electron/security/api-key-manager';
|
||||
import { createWindow } from './electron/windows/main-window';
|
||||
import { startStaticServer, stopStaticServer } from './electron/server/static-server';
|
||||
import { startServer, waitForServer, stopServer } from './electron/server/backend-server';
|
||||
import { registerAllHandlers } from './electron/ipc';
|
||||
|
||||
const logger = createLogger('Electron');
|
||||
const serverLogger = createLogger('Server');
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
// Load environment variables from .env file (development only)
|
||||
if (isDev) {
|
||||
@@ -51,608 +48,18 @@ if (isDev) {
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let staticServer: Server | null = null;
|
||||
|
||||
// Default ports (can be overridden via env) - will be dynamically assigned if these are in use
|
||||
// When launched via root init.mjs we pass:
|
||||
// - PORT (backend)
|
||||
// - TEST_PORT (vite dev server / static)
|
||||
const DEFAULT_SERVER_PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const DEFAULT_STATIC_PORT = parseInt(process.env.TEST_PORT || '3007', 10);
|
||||
|
||||
// Actual ports in use (set during startup)
|
||||
let serverPort = DEFAULT_SERVER_PORT;
|
||||
let staticPort = DEFAULT_STATIC_PORT;
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
*/
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
// Use Node's default binding semantics (matches most dev servers)
|
||||
// This avoids false-positives when a port is taken on IPv6/dual-stack.
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the preferred port
|
||||
* Tries up to 100 ports in sequence
|
||||
*/
|
||||
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
for (let offset = 0; offset < 100; offset++) {
|
||||
const port = preferredPort + offset;
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find an available port starting from ${preferredPort}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Window sizing constants for kanban layout
|
||||
// ============================================
|
||||
// Calculation: 4 columns × 280px + 3 gaps × 20px + 40px padding = 1220px board content
|
||||
// With sidebar expanded (288px): 1220 + 288 = 1508px
|
||||
// Minimum window dimensions - reduced to allow smaller windows since kanban now supports horizontal scrolling
|
||||
const MIN_WIDTH_COLLAPSED = 600; // Reduced - horizontal scrolling handles overflow
|
||||
const MIN_HEIGHT = 500; // Reduced to allow more flexibility
|
||||
const DEFAULT_WIDTH = 1600;
|
||||
const DEFAULT_HEIGHT = 950;
|
||||
|
||||
// Window bounds interface (matches @automaker/types WindowBounds)
|
||||
interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
// Debounce timer for saving window bounds
|
||||
let saveWindowBoundsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// API key for CSRF protection
|
||||
let apiKey: string | null = null;
|
||||
|
||||
// Track if we're using an external server (Docker API mode)
|
||||
let isExternalServerMode = false;
|
||||
|
||||
/**
|
||||
* Get the relative path to API key file within userData
|
||||
*/
|
||||
const API_KEY_FILENAME = '.api-key';
|
||||
|
||||
/**
|
||||
* Ensure an API key exists - load from file or generate new one.
|
||||
* This key is passed to the server for CSRF protection.
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function ensureApiKey(): string {
|
||||
try {
|
||||
if (electronUserDataExists(API_KEY_FILENAME)) {
|
||||
const key = electronUserDataReadFileSync(API_KEY_FILENAME).trim();
|
||||
if (key) {
|
||||
apiKey = key;
|
||||
logger.info('Loaded existing API key');
|
||||
return apiKey;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Error reading API key:', error);
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
apiKey = crypto.randomUUID();
|
||||
try {
|
||||
electronUserDataWriteFileSync(API_KEY_FILENAME, apiKey, { encoding: 'utf-8', mode: 0o600 });
|
||||
logger.info('Generated new API key');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save API key:', error);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon path - works in both dev and production, cross-platform
|
||||
* Uses centralized electronApp methods for path validation.
|
||||
*/
|
||||
function getIconPath(): string | null {
|
||||
let iconFile: string;
|
||||
if (process.platform === 'win32') {
|
||||
iconFile = 'icon.ico';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconFile = 'logo_larger.png';
|
||||
} else {
|
||||
iconFile = 'logo_larger.png';
|
||||
}
|
||||
|
||||
const iconPath = isDev
|
||||
? path.join(__dirname, '../public', iconFile)
|
||||
: path.join(__dirname, '../dist/public', iconFile);
|
||||
|
||||
try {
|
||||
if (!electronAppExists(iconPath)) {
|
||||
logger.warn('Icon not found at:', iconPath);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Icon check failed:', iconPath, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative path to window bounds settings file within userData
|
||||
*/
|
||||
const WINDOW_BOUNDS_FILENAME = 'window-bounds.json';
|
||||
|
||||
/**
|
||||
* Load saved window bounds from disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function loadWindowBounds(): WindowBounds | null {
|
||||
try {
|
||||
if (electronUserDataExists(WINDOW_BOUNDS_FILENAME)) {
|
||||
const data = electronUserDataReadFileSync(WINDOW_BOUNDS_FILENAME);
|
||||
const bounds = JSON.parse(data) as WindowBounds;
|
||||
// Validate the loaded data has required fields
|
||||
if (
|
||||
typeof bounds.x === 'number' &&
|
||||
typeof bounds.y === 'number' &&
|
||||
typeof bounds.width === 'number' &&
|
||||
typeof bounds.height === 'number'
|
||||
) {
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load window bounds:', (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window bounds to disk
|
||||
* Uses centralized electronUserData methods for path validation.
|
||||
*/
|
||||
function saveWindowBounds(bounds: WindowBounds): void {
|
||||
try {
|
||||
electronUserDataWriteFileSync(WINDOW_BOUNDS_FILENAME, JSON.stringify(bounds, null, 2));
|
||||
logger.info('Window bounds saved');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to save window bounds:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a debounced save of window bounds (500ms delay)
|
||||
*/
|
||||
function scheduleSaveWindowBounds(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
if (saveWindowBoundsTimeout) {
|
||||
clearTimeout(saveWindowBoundsTimeout);
|
||||
}
|
||||
|
||||
saveWindowBoundsTimeout = setTimeout(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
// Use getNormalBounds() for maximized windows to save pre-maximized size
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that window bounds are visible on at least one display
|
||||
* Returns adjusted bounds if needed, or null if completely off-screen
|
||||
*/
|
||||
function validateBounds(bounds: WindowBounds): WindowBounds {
|
||||
const displays = screen.getAllDisplays();
|
||||
|
||||
// Check if window center is visible on any display
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
|
||||
let isVisible = false;
|
||||
for (const display of displays) {
|
||||
const { x, y, width, height } = display.workArea;
|
||||
if (centerX >= x && centerX <= x + width && centerY >= y && centerY <= y + height) {
|
||||
isVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
// Window is off-screen, reset to primary display
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { x, y, width, height } = primaryDisplay.workArea;
|
||||
|
||||
return {
|
||||
x: x + Math.floor((width - bounds.width) / 2),
|
||||
y: y + Math.floor((height - bounds.height) / 2),
|
||||
width: Math.min(bounds.width, width),
|
||||
height: Math.min(bounds.height, height),
|
||||
isMaximized: bounds.isMaximized,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure minimum dimensions
|
||||
return {
|
||||
...bounds,
|
||||
width: Math.max(bounds.width, MIN_WIDTH_COLLAPSED),
|
||||
height: Math.max(bounds.height, MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
* Uses centralized electronApp methods for serving static files from app bundle.
|
||||
*/
|
||||
async function startStaticServer(): Promise<void> {
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
|
||||
staticServer = http.createServer((request, response) => {
|
||||
let filePath = path.join(staticPath, request.url?.split('?')[0] || '/');
|
||||
|
||||
if (filePath.endsWith('/')) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
} else if (!path.extname(filePath)) {
|
||||
// For client-side routing, serve index.html for paths without extensions
|
||||
const possibleFile = filePath + '.html';
|
||||
try {
|
||||
if (!electronAppExists(filePath) && !electronAppExists(possibleFile)) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (electronAppExists(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
} catch {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
}
|
||||
|
||||
electronAppStat(filePath, (err, stats) => {
|
||||
if (err || !stats?.isFile()) {
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
|
||||
electronAppReadFile(filePath, (error, content) => {
|
||||
if (error || !content) {
|
||||
response.writeHead(500);
|
||||
response.end('Server Error');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentTypes: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
};
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentTypes[ext] || 'application/octet-stream',
|
||||
});
|
||||
response.end(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer!.listen(staticPort, () => {
|
||||
logger.info('Static server running at http://localhost:' + staticPort);
|
||||
resolve();
|
||||
});
|
||||
staticServer!.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
* Uses centralized methods for path validation.
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
const nodeResult = findNodeExecutable({
|
||||
skipSearch: isDev,
|
||||
logger: (msg: string) => logger.info(msg),
|
||||
});
|
||||
const command = nodeResult.nodePath;
|
||||
|
||||
// Validate that the found Node executable actually exists
|
||||
// systemPathExists is used because node-finder returns system paths
|
||||
if (command !== 'node') {
|
||||
let exists: boolean;
|
||||
try {
|
||||
exists = systemPathExists(command);
|
||||
} catch (error) {
|
||||
const originalError = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to verify Node.js executable at: ${command} (source: ${nodeResult.source}). Reason: ${originalError}`
|
||||
);
|
||||
}
|
||||
if (!exists) {
|
||||
throw new Error(`Node.js executable not found at: ${command} (source: ${nodeResult.source})`);
|
||||
}
|
||||
}
|
||||
|
||||
let args: string[];
|
||||
let serverPath: string;
|
||||
|
||||
if (isDev) {
|
||||
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||
|
||||
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||
|
||||
let tsxCliPath: string;
|
||||
// Check for tsx in app bundle paths
|
||||
try {
|
||||
if (electronAppExists(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (electronAppExists(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
|
||||
args = [tsxCliPath, 'watch', serverPath];
|
||||
} else {
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
try {
|
||||
if (!electronAppExists(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const serverNodeModules = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Server root directory - where .env file is located
|
||||
// In dev: apps/server (not apps/server/src)
|
||||
// In production: resources/server
|
||||
const serverRoot = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server')
|
||||
: path.join(__dirname, '../../server');
|
||||
|
||||
// IMPORTANT: Use shared data directory (not Electron's user data directory)
|
||||
// This ensures Electron and web mode share the same settings/projects
|
||||
// In dev: project root/data (navigate from __dirname which is apps/server/dist or apps/ui/dist-electron)
|
||||
// In production: same as Electron user data (for app isolation)
|
||||
const dataDir = app.isPackaged
|
||||
? app.getPath('userData')
|
||||
: path.join(__dirname, '../../..', 'data');
|
||||
logger.info(
|
||||
`[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
|
||||
);
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
logger.info('Enhanced PATH with Node directory:', path.dirname(command));
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: enhancedPath,
|
||||
PORT: serverPort.toString(),
|
||||
DATA_DIR: dataDir,
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Pass API key to server for CSRF protection
|
||||
AUTOMAKER_API_KEY: apiKey!,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
...(process.env.ALLOWED_ROOT_DIRECTORY && {
|
||||
ALLOWED_ROOT_DIRECTORY: process.env.ALLOWED_ROOT_DIRECTORY,
|
||||
}),
|
||||
};
|
||||
|
||||
logger.info('Server will use port', serverPort);
|
||||
logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
|
||||
|
||||
logger.info('Starting backend server...');
|
||||
logger.info('Server path:', serverPath);
|
||||
logger.info('Server root (cwd):', serverRoot);
|
||||
logger.info('NODE_PATH:', serverNodeModules);
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
cwd: serverRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
serverLogger.info(data.toString().trim());
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
serverLogger.error(data.toString().trim());
|
||||
});
|
||||
|
||||
serverProcess.on('close', (code) => {
|
||||
serverLogger.info('Process exited with code', code);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
serverLogger.error('Failed to start server process:', err);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be available
|
||||
*/
|
||||
async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${serverPort}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
});
|
||||
logger.info('Server is ready');
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Server failed to start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main window
|
||||
*/
|
||||
function createWindow(): void {
|
||||
const iconPath = getIconPath();
|
||||
|
||||
// Load and validate saved window bounds
|
||||
const savedBounds = loadWindowBounds();
|
||||
const validBounds = savedBounds ? validateBounds(savedBounds) : null;
|
||||
|
||||
const windowOptions: Electron.BrowserWindowConstructorOptions = {
|
||||
width: validBounds?.width ?? DEFAULT_WIDTH,
|
||||
height: validBounds?.height ?? DEFAULT_HEIGHT,
|
||||
x: validBounds?.x,
|
||||
y: validBounds?.y,
|
||||
minWidth: MIN_WIDTH_COLLAPSED, // Small minimum - horizontal scrolling handles overflow
|
||||
minHeight: MIN_HEIGHT,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: 'hiddenInset',
|
||||
backgroundColor: '#0a0a0a',
|
||||
};
|
||||
|
||||
if (iconPath) {
|
||||
windowOptions.icon = iconPath;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Restore maximized state if previously maximized
|
||||
if (validBounds?.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Load Vite dev server in development or static server in production
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
} else if (isDev) {
|
||||
// Fallback for dev without Vite server URL
|
||||
mainWindow.loadURL(`http://localhost:${staticPort}`);
|
||||
} else {
|
||||
mainWindow.loadURL(`http://localhost:${staticPort}`);
|
||||
}
|
||||
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// Save window bounds on close, resize, and move
|
||||
mainWindow.on('close', () => {
|
||||
// Save immediately before closing (not debounced)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const isMaximized = mainWindow.isMaximized();
|
||||
const bounds = isMaximized ? mainWindow.getNormalBounds() : mainWindow.getBounds();
|
||||
|
||||
saveWindowBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.on('resized', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.on('moved', () => {
|
||||
scheduleSaveWindowBounds();
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
// Register IPC handlers
|
||||
registerAllHandlers();
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
app.whenReady().then(handleAppReady);
|
||||
app.on('window-all-closed', handleWindowAllClosed);
|
||||
app.on('before-quit', handleBeforeQuit);
|
||||
|
||||
/**
|
||||
* Handle app.whenReady()
|
||||
*/
|
||||
async function handleAppReady(): Promise<void> {
|
||||
// In production, use Automaker dir in appData for app isolation
|
||||
// In development, use project root for shared data between Electron and web mode
|
||||
let userDataPathToUse: string;
|
||||
@@ -661,10 +68,12 @@ app.whenReady().then(async () => {
|
||||
// Production: Ensure userData path is consistent so files land in Automaker dir
|
||||
try {
|
||||
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
|
||||
|
||||
if (app.getPath('userData') !== desiredUserDataPath) {
|
||||
app.setPath('userData', desiredUserDataPath);
|
||||
logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
|
||||
}
|
||||
|
||||
userDataPathToUse = desiredUserDataPath;
|
||||
} catch (error) {
|
||||
logger.warn('[PRODUCTION] Failed to set userData path:', (error as Error).message);
|
||||
@@ -676,6 +85,7 @@ app.whenReady().then(async () => {
|
||||
// __dirname is apps/ui/dist-electron, so go up to get project root
|
||||
const projectRoot = path.join(__dirname, '../../..');
|
||||
userDataPathToUse = path.join(projectRoot, 'data');
|
||||
|
||||
try {
|
||||
app.setPath('userData', userDataPathToUse);
|
||||
logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
|
||||
@@ -701,6 +111,7 @@ app.whenReady().then(async () => {
|
||||
} else {
|
||||
setElectronAppPaths(__dirname, process.resourcesPath);
|
||||
}
|
||||
|
||||
logger.info('Initialized path security helpers');
|
||||
|
||||
// Initialize security settings for path validation
|
||||
@@ -711,6 +122,7 @@ app.whenReady().then(async () => {
|
||||
: path.join(process.cwd(), 'data');
|
||||
process.env.DATA_DIR = mainProcessDataDir;
|
||||
logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
|
||||
|
||||
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
|
||||
// (it will be passed to server process, but we also need it in main process for dialog validation)
|
||||
initAllowedPaths();
|
||||
@@ -729,12 +141,12 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
// Check if we should skip the embedded server (for Docker API mode)
|
||||
const skipEmbeddedServer = process.env.SKIP_EMBEDDED_SERVER === 'true';
|
||||
isExternalServerMode = skipEmbeddedServer;
|
||||
state.isExternalServerMode = skipEmbeddedServer;
|
||||
|
||||
if (skipEmbeddedServer) {
|
||||
// Use the default server port (Docker container runs on 3008)
|
||||
serverPort = DEFAULT_SERVER_PORT;
|
||||
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', serverPort);
|
||||
state.serverPort = DEFAULT_SERVER_PORT;
|
||||
logger.info('SKIP_EMBEDDED_SERVER=true, using external server at port', state.serverPort);
|
||||
|
||||
// Wait for external server to be ready
|
||||
logger.info('Waiting for external server...');
|
||||
@@ -751,15 +163,25 @@ app.whenReady().then(async () => {
|
||||
ensureApiKey();
|
||||
|
||||
// Find available ports (prevents conflicts with other apps using same ports)
|
||||
serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
||||
if (serverPort !== DEFAULT_SERVER_PORT) {
|
||||
logger.info('Default server port', DEFAULT_SERVER_PORT, 'in use, using port', serverPort);
|
||||
state.serverPort = await findAvailablePort(DEFAULT_SERVER_PORT);
|
||||
if (state.serverPort !== DEFAULT_SERVER_PORT) {
|
||||
logger.info(
|
||||
'Default server port',
|
||||
DEFAULT_SERVER_PORT,
|
||||
'in use, using port',
|
||||
state.serverPort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
||||
if (staticPort !== DEFAULT_STATIC_PORT) {
|
||||
logger.info('Default static port', DEFAULT_STATIC_PORT, 'in use, using port', staticPort);
|
||||
state.staticPort = await findAvailablePort(DEFAULT_STATIC_PORT);
|
||||
if (state.staticPort !== DEFAULT_STATIC_PORT) {
|
||||
logger.info(
|
||||
'Default static port',
|
||||
DEFAULT_STATIC_PORT,
|
||||
'in use, using port',
|
||||
state.staticPort
|
||||
);
|
||||
}
|
||||
|
||||
// Start static file server in production
|
||||
@@ -776,8 +198,10 @@ app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
logger.error('Failed to start:', error);
|
||||
|
||||
const errorMessage = (error as Error).message;
|
||||
const isNodeError = errorMessage.includes('Node.js');
|
||||
|
||||
dialog.showErrorBox(
|
||||
'Automaker Failed to Start',
|
||||
`The application failed to start.\n\n${errorMessage}\n\n${
|
||||
@@ -794,207 +218,25 @@ app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
/**
|
||||
* Handle window-all-closed event
|
||||
*/
|
||||
function handleWindowAllClosed(): void {
|
||||
// On macOS, keep the app and servers running when all windows are closed
|
||||
// (standard macOS behavior). On other platforms, stop servers and quit.
|
||||
if (process.platform !== 'darwin') {
|
||||
if (serverProcess && serverProcess.pid) {
|
||||
logger.info('All windows closed, stopping server...');
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill server process:', (error as Error).message);
|
||||
}
|
||||
} else {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
if (staticServer) {
|
||||
logger.info('Stopping static server...');
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
|
||||
stopServer();
|
||||
stopStaticServer();
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (serverProcess && serverProcess.pid) {
|
||||
logger.info('Stopping server...');
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
// Windows: use taskkill with /t to kill entire process tree
|
||||
// This prevents orphaned node processes when closing the app
|
||||
// Using execSync to ensure process is killed before app exits
|
||||
execSync(`taskkill /f /t /pid ${serverProcess.pid}`, { stdio: 'ignore' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to kill server process:', (error as Error).message);
|
||||
}
|
||||
} else {
|
||||
serverProcess.kill('SIGTERM');
|
||||
}
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
if (staticServer) {
|
||||
logger.info('Stopping static server...');
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// IPC Handlers - Only native features
|
||||
// ============================================
|
||||
|
||||
// Native file dialogs
|
||||
ipcMain.handle('dialog:openDirectory', async () => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
|
||||
// Validate selected path against ALLOWED_ROOT_DIRECTORY if configured
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
if (!isPathAllowed(selectedPath)) {
|
||||
const allowedRoot = getAllowedRootDirectory();
|
||||
const errorMessage = allowedRoot
|
||||
? `The selected directory is not allowed. Please select a directory within: ${allowedRoot}`
|
||||
: 'The selected directory is not allowed.';
|
||||
|
||||
await dialog.showErrorBox('Directory Not Allowed', errorMessage);
|
||||
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:openFile', async (_, options = {}) => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openFile'],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePath: undefined };
|
||||
}
|
||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Shell operations
|
||||
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('shell:openPath', async (_, filePath: string) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Open file in editor (VS Code, etc.) with optional line/column
|
||||
ipcMain.handle(
|
||||
'shell:openInEditor',
|
||||
async (_, filePath: string, line?: number, column?: number) => {
|
||||
try {
|
||||
// Build VS Code URL scheme: vscode://file/path:line:column
|
||||
// This works on all platforms where VS Code is installed
|
||||
// URL encode the path to handle special characters (spaces, brackets, etc.)
|
||||
// Handle both Unix (/) and Windows (\) path separators
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const encodedPath = normalizedPath.startsWith('/')
|
||||
? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/')
|
||||
: normalizedPath.split('/').map(encodeURIComponent).join('/');
|
||||
let url = `vscode://file${encodedPath}`;
|
||||
if (line !== undefined && line > 0) {
|
||||
url += `:${line}`;
|
||||
if (column !== undefined && column > 0) {
|
||||
url += `:${column}`;
|
||||
}
|
||||
}
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// App info
|
||||
ipcMain.handle('app:getPath', async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
ipcMain.handle('app:getVersion', async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:isPackaged', async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle('ping', async () => {
|
||||
return 'pong';
|
||||
});
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${serverPort}`;
|
||||
});
|
||||
|
||||
// Get API key for authentication
|
||||
// Returns null in external server mode to trigger session-based auth
|
||||
ipcMain.handle('auth:getApiKey', () => {
|
||||
if (isExternalServerMode) {
|
||||
return null;
|
||||
}
|
||||
return apiKey;
|
||||
});
|
||||
|
||||
// Check if running in external server mode (Docker API)
|
||||
// Used by renderer to determine auth flow
|
||||
ipcMain.handle('auth:isExternalServerMode', () => {
|
||||
return isExternalServerMode;
|
||||
});
|
||||
|
||||
// Window management - update minimum width based on sidebar state
|
||||
// Now uses a fixed small minimum since horizontal scrolling handles overflow
|
||||
ipcMain.handle('window:updateMinWidth', (_, _sidebarExpanded: boolean) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
// Always use the smaller minimum width - horizontal scrolling handles any overflow
|
||||
mainWindow.setMinimumSize(MIN_WIDTH_COLLAPSED, MIN_HEIGHT);
|
||||
});
|
||||
|
||||
// Quit the application (used when user denies sandbox risk confirmation)
|
||||
ipcMain.handle('app:quit', () => {
|
||||
logger.info('Quitting application via IPC request');
|
||||
app.quit();
|
||||
});
|
||||
/**
|
||||
* Handle before-quit event
|
||||
*/
|
||||
function handleBeforeQuit(): void {
|
||||
stopServer();
|
||||
stopStaticServer();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, SaveDialogOptions } from 'electron';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { IPC_CHANNELS } from './electron/ipc/channels';
|
||||
|
||||
const logger = createLogger('Preload');
|
||||
|
||||
@@ -17,48 +18,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
isElectron: true,
|
||||
|
||||
// Connection check
|
||||
ping: (): Promise<string> => ipcRenderer.invoke('ping'),
|
||||
ping: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.PING),
|
||||
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: (): Promise<string> => ipcRenderer.invoke('server:getUrl'),
|
||||
getServerUrl: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.SERVER.GET_URL),
|
||||
|
||||
// Get API key for authentication
|
||||
getApiKey: (): Promise<string | null> => ipcRenderer.invoke('auth:getApiKey'),
|
||||
getApiKey: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.AUTH.GET_API_KEY),
|
||||
|
||||
// Check if running in external server mode (Docker API)
|
||||
isExternalServerMode: (): Promise<boolean> => ipcRenderer.invoke('auth:isExternalServerMode'),
|
||||
isExternalServerMode: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.AUTH.IS_EXTERNAL_SERVER_MODE),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: (): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:openDirectory'),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_DIRECTORY),
|
||||
openFile: (options?: OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:openFile', options),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.OPEN_FILE, options),
|
||||
saveFile: (options?: SaveDialogOptions): Promise<Electron.SaveDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:saveFile', options),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIALOG.SAVE_FILE, options),
|
||||
|
||||
// Shell operations
|
||||
openExternalLink: (url: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('shell:openExternal', url),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_EXTERNAL, url),
|
||||
openPath: (filePath: string): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('shell:openPath', filePath),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_PATH, filePath),
|
||||
openInEditor: (
|
||||
filePath: string,
|
||||
line?: number,
|
||||
column?: number
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke('shell:openInEditor', filePath, line, column),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SHELL.OPEN_IN_EDITOR, filePath, line, column),
|
||||
|
||||
// App info
|
||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke('app:getPath', name),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:getVersion'),
|
||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke('app:isPackaged'),
|
||||
getPath: (name: string): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_PATH, name),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.APP.GET_VERSION),
|
||||
isPackaged: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.APP.IS_PACKAGED),
|
||||
|
||||
// Window management
|
||||
updateMinWidth: (sidebarExpanded: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke('window:updateMinWidth', sidebarExpanded),
|
||||
ipcRenderer.invoke(IPC_CHANNELS.WINDOW.UPDATE_MIN_WIDTH, sidebarExpanded),
|
||||
|
||||
// App control
|
||||
quit: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.APP.QUIT),
|
||||
});
|
||||
|
||||
logger.info('Electron API exposed (TypeScript)');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
13
apps/ui/src/store/defaults/background-settings.ts
Normal file
13
apps/ui/src/store/defaults/background-settings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BackgroundSettings } from '../types/ui-types';
|
||||
|
||||
// Default background settings for board backgrounds
|
||||
export const defaultBackgroundSettings: BackgroundSettings = {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
2
apps/ui/src/store/defaults/constants.ts
Normal file
2
apps/ui/src/store/defaults/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
|
||||
export const MAX_INIT_OUTPUT_LINES = 500;
|
||||
3
apps/ui/src/store/defaults/index.ts
Normal file
3
apps/ui/src/store/defaults/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { defaultBackgroundSettings } from './background-settings';
|
||||
export { defaultTerminalState } from './terminal-defaults';
|
||||
export { MAX_INIT_OUTPUT_LINES } from './constants';
|
||||
21
apps/ui/src/store/defaults/terminal-defaults.ts
Normal file
21
apps/ui/src/store/defaults/terminal-defaults.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import type { TerminalState } from '../types/terminal-types';
|
||||
|
||||
// Default terminal state values
|
||||
export const defaultTerminalState: TerminalState = {
|
||||
isUnlocked: false,
|
||||
authToken: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeSessionId: null,
|
||||
maximizedSessionId: null,
|
||||
defaultFontSize: 14,
|
||||
defaultRunScript: '',
|
||||
screenReaderMode: false,
|
||||
fontFamily: DEFAULT_FONT_VALUE,
|
||||
scrollbackLines: 5000,
|
||||
lineHeight: 1.0,
|
||||
maxSessions: 100,
|
||||
lastActiveProjectPath: null,
|
||||
openTerminalMode: 'newTab',
|
||||
};
|
||||
1
apps/ui/src/store/slices/index.ts
Normal file
1
apps/ui/src/store/slices/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createUISlice, initialUIState, type UISlice } from './ui-slice';
|
||||
343
apps/ui/src/store/slices/ui-slice.ts
Normal file
343
apps/ui/src/store/slices/ui-slice.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { StateCreator } from 'zustand';
|
||||
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
import type {
|
||||
ViewMode,
|
||||
ThemeMode,
|
||||
BoardViewMode,
|
||||
KeyboardShortcuts,
|
||||
BackgroundSettings,
|
||||
UISliceState,
|
||||
UISliceActions,
|
||||
} from '../types/ui-types';
|
||||
import type { AppState, AppActions } from '../types/state-types';
|
||||
import {
|
||||
getStoredTheme,
|
||||
getStoredFontSans,
|
||||
getStoredFontMono,
|
||||
DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
} from '../utils';
|
||||
import { defaultBackgroundSettings } from '../defaults';
|
||||
import {
|
||||
getEffectiveFont,
|
||||
saveThemeToStorage,
|
||||
saveFontSansToStorage,
|
||||
saveFontMonoToStorage,
|
||||
} from '../utils/theme-utils';
|
||||
|
||||
/**
|
||||
* UI Slice
|
||||
* Contains all UI-related state and actions extracted from the main app store.
|
||||
* This is the first slice pattern implementation in the codebase.
|
||||
*/
|
||||
export type UISlice = UISliceState & UISliceActions;
|
||||
|
||||
/**
|
||||
* Initial UI state values
|
||||
*/
|
||||
export const initialUIState: UISliceState = {
|
||||
// Core UI State
|
||||
currentView: 'welcome',
|
||||
sidebarOpen: true,
|
||||
sidebarStyle: 'unified',
|
||||
collapsedNavSections: {},
|
||||
mobileSidebarHidden: false,
|
||||
|
||||
// Theme State
|
||||
theme: getStoredTheme() || 'dark',
|
||||
previewTheme: null,
|
||||
|
||||
// Font State
|
||||
fontFamilySans: getStoredFontSans(),
|
||||
fontFamilyMono: getStoredFontMono(),
|
||||
|
||||
// Board UI State
|
||||
boardViewMode: 'kanban',
|
||||
boardBackgroundByProject: {},
|
||||
|
||||
// Settings UI State
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
muteDoneSound: false,
|
||||
disableSplashScreen: false,
|
||||
showQueryDevtools: true,
|
||||
chatHistoryOpen: false,
|
||||
|
||||
// Panel Visibility State
|
||||
worktreePanelCollapsed: false,
|
||||
worktreePanelVisibleByProject: {},
|
||||
showInitScriptIndicatorByProject: {},
|
||||
autoDismissInitScriptIndicatorByProject: {},
|
||||
|
||||
// File Picker UI State
|
||||
lastProjectDir: '',
|
||||
recentFolders: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the UI slice for the Zustand store.
|
||||
*
|
||||
* Uses the StateCreator pattern to allow the slice to access other parts
|
||||
* of the combined store state (e.g., currentProject for theme resolution).
|
||||
*/
|
||||
export const createUISlice: StateCreator<AppState & AppActions, [], [], UISlice> = (set, get) => ({
|
||||
// Spread initial state
|
||||
...initialUIState,
|
||||
|
||||
// ============================================================================
|
||||
// View Actions
|
||||
// ============================================================================
|
||||
|
||||
setCurrentView: (view: ViewMode) => set({ currentView: view }),
|
||||
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
|
||||
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
|
||||
|
||||
setSidebarStyle: (style: SidebarStyle) => set({ sidebarStyle: style }),
|
||||
|
||||
setCollapsedNavSections: (sections: Record<string, boolean>) =>
|
||||
set({ collapsedNavSections: sections }),
|
||||
|
||||
toggleNavSection: (sectionLabel: string) =>
|
||||
set((state) => ({
|
||||
collapsedNavSections: {
|
||||
...state.collapsedNavSections,
|
||||
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
|
||||
},
|
||||
})),
|
||||
|
||||
toggleMobileSidebarHidden: () =>
|
||||
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
|
||||
|
||||
setMobileSidebarHidden: (hidden: boolean) => set({ mobileSidebarHidden: hidden }),
|
||||
|
||||
// ============================================================================
|
||||
// Theme Actions
|
||||
// ============================================================================
|
||||
|
||||
setTheme: (theme: ThemeMode) => {
|
||||
set({ theme });
|
||||
saveThemeToStorage(theme);
|
||||
},
|
||||
|
||||
getEffectiveTheme: (): ThemeMode => {
|
||||
const state = get();
|
||||
// If there's a preview theme, use it (for hover preview)
|
||||
if (state.previewTheme) return state.previewTheme;
|
||||
// Otherwise, use project theme if set, or fall back to global theme
|
||||
const projectTheme = state.currentProject?.theme as ThemeMode | undefined;
|
||||
return projectTheme ?? state.theme;
|
||||
},
|
||||
|
||||
setPreviewTheme: (theme: ThemeMode | null) => set({ previewTheme: theme }),
|
||||
|
||||
// ============================================================================
|
||||
// Font Actions
|
||||
// ============================================================================
|
||||
|
||||
setFontSans: (fontFamily: string | null) => {
|
||||
set({ fontFamilySans: fontFamily });
|
||||
saveFontSansToStorage(fontFamily);
|
||||
},
|
||||
|
||||
setFontMono: (fontFamily: string | null) => {
|
||||
set({ fontFamilyMono: fontFamily });
|
||||
saveFontMonoToStorage(fontFamily);
|
||||
},
|
||||
|
||||
getEffectiveFontSans: (): string | null => {
|
||||
const state = get();
|
||||
const projectFont = state.currentProject?.fontFamilySans;
|
||||
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
|
||||
},
|
||||
|
||||
getEffectiveFontMono: (): string | null => {
|
||||
const state = get();
|
||||
const projectFont = state.currentProject?.fontFamilyMono;
|
||||
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Board View Actions
|
||||
// ============================================================================
|
||||
|
||||
setBoardViewMode: (mode: BoardViewMode) => set({ boardViewMode: mode }),
|
||||
|
||||
setBoardBackground: (projectPath: string, imagePath: string | null) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
imagePath,
|
||||
imageVersion: Date.now(), // Bust cache on image change
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setCardOpacity: (projectPath: string, opacity: number) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
cardOpacity: opacity,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setColumnOpacity: (projectPath: string, opacity: number) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
columnOpacity: opacity,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setColumnBorderEnabled: (projectPath: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
columnBorderEnabled: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setCardGlassmorphism: (projectPath: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
cardGlassmorphism: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setCardBorderEnabled: (projectPath: string, enabled: boolean) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
cardBorderEnabled: enabled,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setCardBorderOpacity: (projectPath: string, opacity: number) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
cardBorderOpacity: opacity,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
setHideScrollbar: (projectPath: string, hide: boolean) =>
|
||||
set((state) => ({
|
||||
boardBackgroundByProject: {
|
||||
...state.boardBackgroundByProject,
|
||||
[projectPath]: {
|
||||
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
|
||||
hideScrollbar: hide,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
getBoardBackground: (projectPath: string): BackgroundSettings =>
|
||||
get().boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings,
|
||||
|
||||
clearBoardBackground: (projectPath: string) =>
|
||||
set((state) => {
|
||||
const newBackgrounds = { ...state.boardBackgroundByProject };
|
||||
delete newBackgrounds[projectPath];
|
||||
return { boardBackgroundByProject: newBackgrounds };
|
||||
}),
|
||||
|
||||
// ============================================================================
|
||||
// Settings UI Actions
|
||||
// ============================================================================
|
||||
|
||||
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) =>
|
||||
set((state) => ({
|
||||
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
|
||||
})),
|
||||
|
||||
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) =>
|
||||
set((state) => ({
|
||||
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
|
||||
})),
|
||||
|
||||
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
|
||||
|
||||
setMuteDoneSound: (muted: boolean) => set({ muteDoneSound: muted }),
|
||||
|
||||
setDisableSplashScreen: (disabled: boolean) => set({ disableSplashScreen: disabled }),
|
||||
|
||||
setShowQueryDevtools: (show: boolean) => set({ showQueryDevtools: show }),
|
||||
|
||||
setChatHistoryOpen: (open: boolean) => set({ chatHistoryOpen: open }),
|
||||
|
||||
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
|
||||
|
||||
// ============================================================================
|
||||
// Panel Visibility Actions
|
||||
// ============================================================================
|
||||
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => set({ worktreePanelCollapsed: collapsed }),
|
||||
|
||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
worktreePanelVisibleByProject: {
|
||||
...state.worktreePanelVisibleByProject,
|
||||
[projectPath]: visible,
|
||||
},
|
||||
})),
|
||||
|
||||
getWorktreePanelVisible: (projectPath: string): boolean =>
|
||||
get().worktreePanelVisibleByProject[projectPath] ?? true,
|
||||
|
||||
setShowInitScriptIndicator: (projectPath: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
showInitScriptIndicatorByProject: {
|
||||
...state.showInitScriptIndicatorByProject,
|
||||
[projectPath]: visible,
|
||||
},
|
||||
})),
|
||||
|
||||
getShowInitScriptIndicator: (projectPath: string): boolean =>
|
||||
get().showInitScriptIndicatorByProject[projectPath] ?? true,
|
||||
|
||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) =>
|
||||
set((state) => ({
|
||||
autoDismissInitScriptIndicatorByProject: {
|
||||
...state.autoDismissInitScriptIndicatorByProject,
|
||||
[projectPath]: autoDismiss,
|
||||
},
|
||||
})),
|
||||
|
||||
getAutoDismissInitScriptIndicator: (projectPath: string): boolean =>
|
||||
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
|
||||
|
||||
// ============================================================================
|
||||
// File Picker UI Actions
|
||||
// ============================================================================
|
||||
|
||||
setLastProjectDir: (dir: string) => set({ lastProjectDir: dir }),
|
||||
|
||||
setRecentFolders: (folders: string[]) => set({ recentFolders: folders }),
|
||||
|
||||
addRecentFolder: (folder: string) =>
|
||||
set((state) => {
|
||||
const filtered = state.recentFolders.filter((f) => f !== folder);
|
||||
return { recentFolders: [folder, ...filtered].slice(0, 10) };
|
||||
}),
|
||||
});
|
||||
40
apps/ui/src/store/types/chat-types.ts
Normal file
40
apps/ui/src/store/types/chat-types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface ImageAttachment {
|
||||
id?: string; // Optional - may not be present in messages loaded from server
|
||||
data: string; // base64 encoded image data
|
||||
mimeType: string; // e.g., "image/png", "image/jpeg"
|
||||
filename: string;
|
||||
size?: number; // file size in bytes - optional for messages from server
|
||||
}
|
||||
|
||||
export interface TextFileAttachment {
|
||||
id: string;
|
||||
content: string; // text content of the file
|
||||
mimeType: string; // e.g., "text/plain", "text/markdown"
|
||||
filename: string;
|
||||
size: number; // file size in bytes
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
images?: ImageAttachment[];
|
||||
textFiles?: TextFileAttachment[];
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
// UI-specific: base64-encoded images with required id and size (extends ImageAttachment)
|
||||
export interface FeatureImage extends ImageAttachment {
|
||||
id: string; // Required (overrides optional in ImageAttachment)
|
||||
size: number; // Required (overrides optional in ImageAttachment)
|
||||
}
|
||||
7
apps/ui/src/store/types/index.ts
Normal file
7
apps/ui/src/store/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './usage-types';
|
||||
export * from './ui-types';
|
||||
export * from './settings-types';
|
||||
export * from './chat-types';
|
||||
export * from './terminal-types';
|
||||
export * from './project-types';
|
||||
export * from './state-types';
|
||||
66
apps/ui/src/store/types/project-types.ts
Normal file
66
apps/ui/src/store/types/project-types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
FeatureStatusWithPipeline,
|
||||
PlanSpec,
|
||||
} from '@automaker/types';
|
||||
import type { FeatureImage } from './chat-types';
|
||||
|
||||
// Available models for feature execution
|
||||
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
||||
|
||||
export interface Feature extends Omit<
|
||||
BaseFeature,
|
||||
| 'steps'
|
||||
| 'imagePaths'
|
||||
| 'textFilePaths'
|
||||
| 'status'
|
||||
| 'planSpec'
|
||||
| 'dependencies'
|
||||
| 'model'
|
||||
| 'branchName'
|
||||
| 'thinkingLevel'
|
||||
| 'reasoningEffort'
|
||||
| 'summary'
|
||||
> {
|
||||
id: string;
|
||||
title?: string;
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[]; // Required in UI (not optional)
|
||||
status: FeatureStatusWithPipeline;
|
||||
images?: FeatureImage[]; // UI-specific base64 images
|
||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||
prUrl?: string; // UI-specific: Pull request URL
|
||||
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
|
||||
dependencies?: string[]; // Explicit type to override BaseFeature's index signature
|
||||
model?: string; // Explicit type to override BaseFeature's index signature
|
||||
branchName?: string; // Explicit type to override BaseFeature's index signature
|
||||
thinkingLevel?: ThinkingLevel; // Explicit type to override BaseFeature's index signature
|
||||
reasoningEffort?: ReasoningEffort; // Explicit type to override BaseFeature's index signature
|
||||
summary?: string; // Explicit type to override BaseFeature's index signature
|
||||
}
|
||||
|
||||
// File tree node for project analysis
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
extension?: string;
|
||||
children?: FileTreeNode[];
|
||||
}
|
||||
|
||||
// Project analysis result
|
||||
export interface ProjectAnalysis {
|
||||
fileTree: FileTreeNode[];
|
||||
totalFiles: number;
|
||||
totalDirectories: number;
|
||||
filesByExtension: Record<string, number>;
|
||||
analyzedAt: string;
|
||||
}
|
||||
5
apps/ui/src/store/types/settings-types.ts
Normal file
5
apps/ui/src/store/types/settings-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ApiKeys {
|
||||
anthropic: string;
|
||||
google: string;
|
||||
openai: string;
|
||||
}
|
||||
799
apps/ui/src/store/types/state-types.ts
Normal file
799
apps/ui/src/store/types/state-types.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
ModelAlias,
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ModelProvider,
|
||||
CursorModelId,
|
||||
CodexModelId,
|
||||
OpencodeModelId,
|
||||
GeminiModelId,
|
||||
CopilotModelId,
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
MCPServerConfig,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
PromptCustomization,
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
} from '@automaker/types';
|
||||
|
||||
import type {
|
||||
ViewMode,
|
||||
ThemeMode,
|
||||
BoardViewMode,
|
||||
KeyboardShortcuts,
|
||||
BackgroundSettings,
|
||||
} from './ui-types';
|
||||
import type { ApiKeys } from './settings-types';
|
||||
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
|
||||
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
||||
import type { Feature, ProjectAnalysis } from './project-types';
|
||||
import type { ClaudeUsage, CodexUsage } from './usage-types';
|
||||
|
||||
/** State for worktree init script execution */
|
||||
export interface InitScriptState {
|
||||
status: 'idle' | 'running' | 'success' | 'failed';
|
||||
branch: string;
|
||||
output: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AutoModeActivity {
|
||||
id: string;
|
||||
featureId: string;
|
||||
timestamp: Date;
|
||||
type:
|
||||
| 'start'
|
||||
| 'progress'
|
||||
| 'tool'
|
||||
| 'complete'
|
||||
| 'error'
|
||||
| 'planning'
|
||||
| 'action'
|
||||
| 'verification';
|
||||
message: string;
|
||||
tool?: string;
|
||||
passes?: boolean;
|
||||
phase?: 'planning' | 'action' | 'verification';
|
||||
errorType?: 'authentication' | 'execution';
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Project state
|
||||
projects: Project[];
|
||||
currentProject: Project | null;
|
||||
trashedProjects: TrashedProject[];
|
||||
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
|
||||
projectHistoryIndex: number; // Current position in project history for cycling
|
||||
|
||||
// View state
|
||||
currentView: ViewMode;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
|
||||
collapsedNavSections: Record<string, boolean>; // Collapsed state of nav sections (key: section label)
|
||||
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
|
||||
|
||||
// Agent Session state (per-project, keyed by project path)
|
||||
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
|
||||
|
||||
// Theme
|
||||
theme: ThemeMode;
|
||||
|
||||
// Fonts (global defaults)
|
||||
fontFamilySans: string | null; // null = use default Geist Sans
|
||||
fontFamilyMono: string | null; // null = use default Geist Mono
|
||||
|
||||
// Features/Kanban
|
||||
features: Feature[];
|
||||
|
||||
// App spec
|
||||
appSpec: string;
|
||||
|
||||
// IPC status
|
||||
ipcConnected: boolean;
|
||||
|
||||
// API Keys
|
||||
apiKeys: ApiKeys;
|
||||
|
||||
// Chat Sessions
|
||||
chatSessions: ChatSession[];
|
||||
currentChatSession: ChatSession | null;
|
||||
chatHistoryOpen: boolean;
|
||||
|
||||
// Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
|
||||
autoModeByWorktree: Record<
|
||||
string,
|
||||
{
|
||||
isRunning: boolean;
|
||||
runningTasks: string[]; // Feature IDs being worked on
|
||||
branchName: string | null; // null = main worktree
|
||||
maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
|
||||
}
|
||||
>;
|
||||
autoModeActivityLog: AutoModeActivity[];
|
||||
maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
|
||||
|
||||
// Kanban Card Display Settings
|
||||
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||
|
||||
// Feature Default Settings
|
||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
|
||||
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
|
||||
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
|
||||
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
|
||||
|
||||
// Worktree Settings
|
||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
|
||||
|
||||
// User-managed Worktrees (per-project)
|
||||
// projectPath -> { path: worktreePath or null for main, branch: branch name }
|
||||
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
|
||||
worktreesByProject: Record<
|
||||
string,
|
||||
Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>
|
||||
>;
|
||||
|
||||
// Keyboard Shortcuts
|
||||
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
|
||||
|
||||
// Audio Settings
|
||||
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
|
||||
|
||||
// Splash Screen Settings
|
||||
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
|
||||
|
||||
// Server Log Level Settings
|
||||
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
|
||||
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
|
||||
|
||||
// Developer Tools Settings
|
||||
showQueryDevtools: boolean; // Show React Query DevTools panel (only in development mode)
|
||||
|
||||
// Enhancement Model Settings
|
||||
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
|
||||
|
||||
// Validation Model Settings
|
||||
validationModel: ModelAlias; // Model used for GitHub issue validation (default: opus)
|
||||
|
||||
// Phase Model Settings - per-phase AI model configuration
|
||||
phaseModels: PhaseModelConfig;
|
||||
favoriteModels: string[];
|
||||
|
||||
// Cursor CLI Settings (global)
|
||||
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
|
||||
cursorDefaultModel: CursorModelId; // Default Cursor model selection
|
||||
|
||||
// Codex CLI Settings (global)
|
||||
enabledCodexModels: CodexModelId[]; // Which Codex models are available in feature modal
|
||||
codexDefaultModel: CodexModelId; // Default Codex model selection
|
||||
codexAutoLoadAgents: boolean; // Auto-load .codex/AGENTS.md files
|
||||
codexSandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; // Sandbox policy
|
||||
codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy
|
||||
codexEnableWebSearch: boolean; // Enable web search capability
|
||||
codexEnableImages: boolean; // Enable image processing
|
||||
|
||||
// OpenCode CLI Settings (global)
|
||||
// Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC
|
||||
enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available
|
||||
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
|
||||
// Dynamic models are session-only (not persisted) because they're discovered at runtime
|
||||
// from `opencode models` CLI and depend on current provider authentication state
|
||||
dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI
|
||||
enabledDynamicModelIds: string[]; // Which dynamic models are enabled
|
||||
cachedOpencodeProviders: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: string;
|
||||
}>; // Cached providers
|
||||
opencodeModelsLoading: boolean; // Whether OpenCode models are being fetched
|
||||
opencodeModelsError: string | null; // Error message if fetch failed
|
||||
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
|
||||
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
|
||||
|
||||
// Gemini CLI Settings (global)
|
||||
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
|
||||
geminiDefaultModel: GeminiModelId; // Default Gemini model selection
|
||||
|
||||
// Copilot SDK Settings (global)
|
||||
enabledCopilotModels: CopilotModelId[]; // Which Copilot models are available in feature modal
|
||||
copilotDefaultModel: CopilotModelId; // Default Copilot model selection
|
||||
|
||||
// Provider Visibility Settings
|
||||
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
|
||||
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
|
||||
// Editor Configuration
|
||||
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||
|
||||
// Terminal Configuration
|
||||
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
|
||||
|
||||
// Skills Configuration
|
||||
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
|
||||
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
|
||||
|
||||
// Subagents Configuration
|
||||
enableSubagents: boolean; // Enable Custom Subagents functionality (loads from .claude/agents/ directories)
|
||||
subagentsSources: Array<'user' | 'project'>; // Which directories to load Subagents from
|
||||
|
||||
// Prompt Customization
|
||||
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
|
||||
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Claude-Compatible Providers (new system)
|
||||
claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
|
||||
|
||||
// Claude API Profiles (deprecated - kept for backward compatibility)
|
||||
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
|
||||
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
|
||||
|
||||
// Project Analysis
|
||||
projectAnalysis: ProjectAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
|
||||
// Board Background Settings (per-project, keyed by project path)
|
||||
boardBackgroundByProject: Record<string, BackgroundSettings>;
|
||||
|
||||
// Theme Preview (for hover preview in theme selectors)
|
||||
previewTheme: ThemeMode | null;
|
||||
|
||||
// Terminal state
|
||||
terminalState: TerminalState;
|
||||
|
||||
// Terminal layout persistence (per-project, keyed by project path)
|
||||
// Stores the tab/split structure so it can be restored when switching projects
|
||||
terminalLayoutByProject: Record<string, PersistedTerminalState>;
|
||||
|
||||
// Spec Creation State (per-project, keyed by project path)
|
||||
// Tracks which project is currently having its spec generated
|
||||
specCreatingForProject: string | null;
|
||||
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultFeatureModel: PhaseModelEntry;
|
||||
|
||||
// Plan Approval State
|
||||
// When a plan requires user approval, this holds the pending approval details
|
||||
pendingPlanApproval: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: 'lite' | 'spec' | 'full';
|
||||
} | null;
|
||||
|
||||
// Claude Usage Tracking
|
||||
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
|
||||
claudeUsage: ClaudeUsage | null;
|
||||
claudeUsageLastUpdated: number | null;
|
||||
|
||||
// Codex Usage Tracking
|
||||
codexUsage: CodexUsage | null;
|
||||
codexUsageLastUpdated: number | null;
|
||||
|
||||
// Codex Models (dynamically fetched)
|
||||
codexModels: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
codexModelsLoading: boolean;
|
||||
codexModelsError: string | null;
|
||||
codexModelsLastFetched: number | null;
|
||||
codexModelsLastFailedAt: number | null;
|
||||
|
||||
// Pipeline Configuration (per-project, keyed by project path)
|
||||
pipelineConfigByProject: Record<string, PipelineConfig>;
|
||||
|
||||
// Worktree Panel Visibility (per-project, keyed by project path)
|
||||
// Whether the worktree panel row is visible (default: true)
|
||||
worktreePanelVisibleByProject: Record<string, boolean>;
|
||||
|
||||
// Init Script Indicator Visibility (per-project, keyed by project path)
|
||||
// Whether to show the floating init script indicator panel (default: true)
|
||||
showInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
|
||||
// Default Delete Branch With Worktree (per-project, keyed by project path)
|
||||
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
|
||||
defaultDeleteBranchByProject: Record<string, boolean>;
|
||||
|
||||
// Auto-dismiss Init Script Indicator (per-project, keyed by project path)
|
||||
// Whether to auto-dismiss the indicator after completion (default: true)
|
||||
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
|
||||
// Use Worktrees Override (per-project, keyed by project path)
|
||||
// undefined = use global setting, true/false = project-specific override
|
||||
useWorktreesByProject: Record<string, boolean | undefined>;
|
||||
|
||||
// UI State (previously in localStorage, now synced via API)
|
||||
/** Whether worktree panel is collapsed in board view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
/** Last directory opened in file picker */
|
||||
lastProjectDir: string;
|
||||
/** Recently accessed folders for quick access */
|
||||
recentFolders: string[];
|
||||
|
||||
// Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
|
||||
initScriptState: Record<string, InitScriptState>;
|
||||
}
|
||||
|
||||
export interface AppActions {
|
||||
// Project actions
|
||||
setProjects: (projects: Project[]) => void;
|
||||
addProject: (project: Project) => void;
|
||||
removeProject: (projectId: string) => void;
|
||||
moveProjectToTrash: (projectId: string) => void;
|
||||
restoreTrashedProject: (projectId: string) => void;
|
||||
deleteTrashedProject: (projectId: string) => void;
|
||||
emptyTrash: () => void;
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
upsertAndSetCurrentProject: (path: string, name: string, theme?: ThemeMode) => Project; // Upsert project by path and set as current
|
||||
reorderProjects: (oldIndex: number, newIndex: number) => void;
|
||||
cyclePrevProject: () => void; // Cycle back through project history (Q)
|
||||
cycleNextProject: () => void; // Cycle forward through project history (E)
|
||||
clearProjectHistory: () => void; // Clear history, keeping only current project
|
||||
toggleProjectFavorite: (projectId: string) => void; // Toggle project favorite status
|
||||
setProjectIcon: (projectId: string, icon: string | null) => void; // Set project icon (null to clear)
|
||||
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => void; // Set custom project icon image path (null to clear)
|
||||
setProjectName: (projectId: string, name: string) => void; // Update project name
|
||||
|
||||
// View actions
|
||||
setCurrentView: (view: ViewMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSidebarStyle: (style: SidebarStyle) => void;
|
||||
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
|
||||
toggleNavSection: (sectionLabel: string) => void;
|
||||
toggleMobileSidebarHidden: () => void;
|
||||
setMobileSidebarHidden: (hidden: boolean) => void;
|
||||
|
||||
// Theme actions
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
|
||||
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
|
||||
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
|
||||
|
||||
// Font actions (global + per-project override)
|
||||
setFontSans: (fontFamily: string | null) => void; // Set global UI/sans font (null to clear)
|
||||
setFontMono: (fontFamily: string | null) => void; // Set global code/mono font (null to clear)
|
||||
setProjectFontSans: (projectId: string, fontFamily: string | null) => void; // Set per-project UI/sans font override (null = use global)
|
||||
setProjectFontMono: (projectId: string, fontFamily: string | null) => void; // Set per-project code/mono font override (null = use global)
|
||||
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
|
||||
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
|
||||
|
||||
// Claude API Profile actions (per-project override)
|
||||
/** @deprecated Use setProjectPhaseModelOverride instead */
|
||||
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
|
||||
|
||||
// Project Phase Model Overrides
|
||||
setProjectPhaseModelOverride: (
|
||||
projectId: string,
|
||||
phase: PhaseModelKey,
|
||||
entry: PhaseModelEntry | null // null = use global
|
||||
) => void;
|
||||
clearAllProjectPhaseModelOverrides: (projectId: string) => void;
|
||||
|
||||
// Project Default Feature Model Override
|
||||
setProjectDefaultFeatureModel: (
|
||||
projectId: string,
|
||||
entry: PhaseModelEntry | null // null = use global
|
||||
) => void;
|
||||
|
||||
// Feature actions
|
||||
setFeatures: (features: Feature[]) => void;
|
||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
|
||||
removeFeature: (id: string) => void;
|
||||
moveFeature: (id: string, newStatus: Feature['status']) => void;
|
||||
|
||||
// App spec actions
|
||||
setAppSpec: (spec: string) => void;
|
||||
|
||||
// IPC actions
|
||||
setIpcConnected: (connected: boolean) => void;
|
||||
|
||||
// API Keys actions
|
||||
setApiKeys: (keys: Partial<ApiKeys>) => void;
|
||||
|
||||
// Chat Session actions
|
||||
createChatSession: (title?: string) => ChatSession;
|
||||
updateChatSession: (sessionId: string, updates: Partial<ChatSession>) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
setCurrentChatSession: (session: ChatSession | null) => void;
|
||||
archiveChatSession: (sessionId: string) => void;
|
||||
unarchiveChatSession: (sessionId: string) => void;
|
||||
deleteChatSession: (sessionId: string) => void;
|
||||
setChatHistoryOpen: (open: boolean) => void;
|
||||
toggleChatHistory: () => void;
|
||||
|
||||
// Auto Mode actions (per-worktree)
|
||||
setAutoModeRunning: (
|
||||
projectId: string,
|
||||
branchName: string | null,
|
||||
running: boolean,
|
||||
maxConcurrency?: number,
|
||||
runningTasks?: string[]
|
||||
) => void;
|
||||
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
|
||||
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
|
||||
clearRunningTasks: (projectId: string, branchName: string | null) => void;
|
||||
getAutoModeState: (
|
||||
projectId: string,
|
||||
branchName: string | null
|
||||
) => {
|
||||
isRunning: boolean;
|
||||
runningTasks: string[];
|
||||
branchName: string | null;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
/** Helper to generate worktree key from projectId and branchName */
|
||||
getWorktreeKey: (projectId: string, branchName: string | null) => string;
|
||||
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
|
||||
clearAutoModeActivity: () => void;
|
||||
setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
|
||||
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
|
||||
setMaxConcurrencyForWorktree: (
|
||||
projectId: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => void;
|
||||
|
||||
// Kanban Card Settings actions
|
||||
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||
|
||||
// Feature Default Settings actions
|
||||
setDefaultSkipTests: (skip: boolean) => void;
|
||||
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
|
||||
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
|
||||
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// Worktree Settings actions
|
||||
setUseWorktrees: (enabled: boolean) => void;
|
||||
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
|
||||
setWorktrees: (
|
||||
projectPath: string,
|
||||
worktrees: Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>
|
||||
) => void;
|
||||
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
|
||||
getWorktrees: (projectPath: string) => Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
isCurrent: boolean;
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
|
||||
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
|
||||
|
||||
// Keyboard Shortcuts actions
|
||||
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
|
||||
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
|
||||
resetKeyboardShortcuts: () => void;
|
||||
|
||||
// Audio Settings actions
|
||||
setMuteDoneSound: (muted: boolean) => void;
|
||||
|
||||
// Splash Screen actions
|
||||
setDisableSplashScreen: (disabled: boolean) => void;
|
||||
|
||||
// Server Log Level actions
|
||||
setServerLogLevel: (level: ServerLogLevel) => void;
|
||||
setEnableRequestLogging: (enabled: boolean) => void;
|
||||
|
||||
// Developer Tools actions
|
||||
setShowQueryDevtools: (show: boolean) => void;
|
||||
|
||||
// Enhancement Model actions
|
||||
setEnhancementModel: (model: ModelAlias) => void;
|
||||
|
||||
// Validation Model actions
|
||||
setValidationModel: (model: ModelAlias) => void;
|
||||
|
||||
// Phase Model actions
|
||||
setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise<void>;
|
||||
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
|
||||
resetPhaseModels: () => Promise<void>;
|
||||
toggleFavoriteModel: (modelId: string) => void;
|
||||
|
||||
// Cursor CLI Settings actions
|
||||
setEnabledCursorModels: (models: CursorModelId[]) => void;
|
||||
setCursorDefaultModel: (model: CursorModelId) => void;
|
||||
toggleCursorModel: (model: CursorModelId, enabled: boolean) => void;
|
||||
|
||||
// Codex CLI Settings actions
|
||||
setEnabledCodexModels: (models: CodexModelId[]) => void;
|
||||
setCodexDefaultModel: (model: CodexModelId) => void;
|
||||
toggleCodexModel: (model: CodexModelId, enabled: boolean) => void;
|
||||
setCodexAutoLoadAgents: (enabled: boolean) => Promise<void>;
|
||||
setCodexSandboxMode: (
|
||||
mode: 'read-only' | 'workspace-write' | 'danger-full-access'
|
||||
) => Promise<void>;
|
||||
setCodexApprovalPolicy: (
|
||||
policy: 'untrusted' | 'on-failure' | 'on-request' | 'never'
|
||||
) => Promise<void>;
|
||||
setCodexEnableWebSearch: (enabled: boolean) => Promise<void>;
|
||||
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
||||
|
||||
// OpenCode CLI Settings actions
|
||||
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
||||
setDynamicOpencodeModels: (models: ModelDefinition[]) => void;
|
||||
setEnabledDynamicModelIds: (ids: string[]) => void;
|
||||
toggleDynamicModel: (modelId: string, enabled: boolean) => void;
|
||||
setCachedOpencodeProviders: (
|
||||
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||
) => void;
|
||||
|
||||
// Gemini CLI Settings actions
|
||||
setEnabledGeminiModels: (models: GeminiModelId[]) => void;
|
||||
setGeminiDefaultModel: (model: GeminiModelId) => void;
|
||||
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
|
||||
|
||||
// Copilot SDK Settings actions
|
||||
setEnabledCopilotModels: (models: CopilotModelId[]) => void;
|
||||
setCopilotDefaultModel: (model: CopilotModelId) => void;
|
||||
toggleCopilotModel: (model: CopilotModelId, enabled: boolean) => void;
|
||||
|
||||
// Provider Visibility Settings actions
|
||||
setDisabledProviders: (providers: ModelProvider[]) => void;
|
||||
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
|
||||
isProviderDisabled: (provider: ModelProvider) => boolean;
|
||||
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command: string | null) => void;
|
||||
|
||||
// Terminal Configuration actions
|
||||
setDefaultTerminalId: (terminalId: string | null) => void;
|
||||
|
||||
// Prompt Customization actions
|
||||
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
|
||||
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => void;
|
||||
|
||||
// Claude-Compatible Provider actions (new system)
|
||||
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
|
||||
updateClaudeCompatibleProvider: (
|
||||
id: string,
|
||||
updates: Partial<ClaudeCompatibleProvider>
|
||||
) => Promise<void>;
|
||||
deleteClaudeCompatibleProvider: (id: string) => Promise<void>;
|
||||
setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise<void>;
|
||||
toggleClaudeCompatibleProviderEnabled: (id: string) => Promise<void>;
|
||||
|
||||
// Claude API Profile actions (deprecated - kept for backward compatibility)
|
||||
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
|
||||
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
|
||||
deleteClaudeApiProfile: (id: string) => Promise<void>;
|
||||
setActiveClaudeApiProfile: (id: string | null) => Promise<void>;
|
||||
setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise<void>;
|
||||
|
||||
// MCP Server actions
|
||||
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
||||
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
||||
removeMCPServer: (id: string) => void;
|
||||
reorderMCPServers: (oldIndex: number, newIndex: number) => void;
|
||||
|
||||
// Project Analysis actions
|
||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||
setIsAnalyzing: (analyzing: boolean) => void;
|
||||
clearAnalysis: () => void;
|
||||
|
||||
// Agent Session actions
|
||||
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
|
||||
getLastSelectedSession: (projectPath: string) => string | null;
|
||||
|
||||
// Board Background actions
|
||||
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
|
||||
setCardOpacity: (projectPath: string, opacity: number) => void;
|
||||
setColumnOpacity: (projectPath: string, opacity: number) => void;
|
||||
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
getBoardBackground: (projectPath: string) => {
|
||||
imagePath: string | null;
|
||||
cardOpacity: number;
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
|
||||
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||
clearBoardBackground: (projectPath: string) => void;
|
||||
|
||||
// Terminal actions
|
||||
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
|
||||
setActiveTerminalSession: (sessionId: string | null) => void;
|
||||
toggleTerminalMaximized: (sessionId: string) => void;
|
||||
addTerminalToLayout: (
|
||||
sessionId: string,
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
targetSessionId?: string,
|
||||
branchName?: string
|
||||
) => void;
|
||||
removeTerminalFromLayout: (sessionId: string) => void;
|
||||
swapTerminals: (sessionId1: string, sessionId2: string) => void;
|
||||
clearTerminalState: () => void;
|
||||
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
|
||||
setTerminalDefaultFontSize: (fontSize: number) => void;
|
||||
setTerminalDefaultRunScript: (script: string) => void;
|
||||
setTerminalScreenReaderMode: (enabled: boolean) => void;
|
||||
setTerminalFontFamily: (fontFamily: string) => void;
|
||||
setTerminalScrollbackLines: (lines: number) => void;
|
||||
setTerminalLineHeight: (lineHeight: number) => void;
|
||||
setTerminalMaxSessions: (maxSessions: number) => void;
|
||||
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
|
||||
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
|
||||
addTerminalTab: (name?: string) => string;
|
||||
removeTerminalTab: (tabId: string) => void;
|
||||
setActiveTerminalTab: (tabId: string) => void;
|
||||
renameTerminalTab: (tabId: string, name: string) => void;
|
||||
reorderTerminalTabs: (fromTabId: string, toTabId: string) => void;
|
||||
moveTerminalToTab: (sessionId: string, targetTabId: string | 'new') => void;
|
||||
addTerminalToTab: (
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
direction?: 'horizontal' | 'vertical',
|
||||
branchName?: string
|
||||
) => void;
|
||||
setTerminalTabLayout: (
|
||||
tabId: string,
|
||||
layout: TerminalPanelContent,
|
||||
activeSessionId?: string
|
||||
) => void;
|
||||
updateTerminalPanelSizes: (tabId: string, panelKeys: string[], sizes: number[]) => void;
|
||||
saveTerminalLayout: (projectPath: string) => void;
|
||||
getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null;
|
||||
clearPersistedTerminalLayout: (projectPath: string) => void;
|
||||
|
||||
// Spec Creation actions
|
||||
setSpecCreatingForProject: (projectPath: string | null) => void;
|
||||
isSpecCreatingForProject: (projectPath: string) => boolean;
|
||||
|
||||
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
||||
setDefaultRequirePlanApproval: (require: boolean) => void;
|
||||
setDefaultFeatureModel: (entry: PhaseModelEntry) => void;
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (
|
||||
approval: {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
planContent: string;
|
||||
planningMode: 'lite' | 'spec' | 'full';
|
||||
} | null
|
||||
) => void;
|
||||
|
||||
// Pipeline actions
|
||||
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
|
||||
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
|
||||
addPipelineStep: (
|
||||
projectPath: string,
|
||||
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
|
||||
) => PipelineStep;
|
||||
updatePipelineStep: (
|
||||
projectPath: string,
|
||||
stepId: string,
|
||||
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
|
||||
) => void;
|
||||
deletePipelineStep: (projectPath: string, stepId: string) => void;
|
||||
reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void;
|
||||
|
||||
// Worktree Panel Visibility actions (per-project)
|
||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
||||
getWorktreePanelVisible: (projectPath: string) => boolean;
|
||||
|
||||
// Init Script Indicator Visibility actions (per-project)
|
||||
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
|
||||
getShowInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// Default Delete Branch actions (per-project)
|
||||
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
|
||||
getDefaultDeleteBranch: (projectPath: string) => boolean;
|
||||
|
||||
// Auto-dismiss Init Script Indicator actions (per-project)
|
||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// Use Worktrees Override actions (per-project)
|
||||
setProjectUseWorktrees: (projectPath: string, useWorktrees: boolean | null) => void; // null = use global
|
||||
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
|
||||
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
|
||||
|
||||
// UI State actions (previously in localStorage, now synced via API)
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
setRecentFolders: (folders: string[]) => void;
|
||||
addRecentFolder: (folder: string) => void;
|
||||
|
||||
// Claude Usage Tracking actions
|
||||
setClaudeRefreshInterval: (interval: number) => void;
|
||||
setClaudeUsageLastUpdated: (timestamp: number) => void;
|
||||
setClaudeUsage: (usage: ClaudeUsage | null) => void;
|
||||
|
||||
// Codex Usage Tracking actions
|
||||
setCodexUsage: (usage: CodexUsage | null) => void;
|
||||
|
||||
// Codex Models actions
|
||||
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
||||
setCodexModels: (
|
||||
models: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
hasThinking: boolean;
|
||||
supportsVision: boolean;
|
||||
tier: 'premium' | 'standard' | 'basic';
|
||||
isDefault: boolean;
|
||||
}>
|
||||
) => void;
|
||||
|
||||
// OpenCode Models actions
|
||||
fetchOpencodeModels: (forceRefresh?: boolean) => Promise<void>;
|
||||
|
||||
// Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
|
||||
setInitScriptState: (
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
state: Partial<InitScriptState>
|
||||
) => void;
|
||||
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
|
||||
clearInitScriptState: (projectPath: string, branch: string) => void;
|
||||
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
|
||||
getInitScriptStatesForProject: (
|
||||
projectPath: string
|
||||
) => Array<{ key: string; state: InitScriptState }>;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
}
|
||||
82
apps/ui/src/store/types/terminal-types.ts
Normal file
82
apps/ui/src/store/types/terminal-types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Terminal panel layout types (recursive for splits)
|
||||
export type TerminalPanelContent =
|
||||
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
|
||||
| { type: 'testRunner'; sessionId: string; size?: number; worktreePath: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id: string; // Stable ID for React key stability
|
||||
direction: 'horizontal' | 'vertical';
|
||||
panels: TerminalPanelContent[];
|
||||
size?: number;
|
||||
};
|
||||
|
||||
// Terminal tab - each tab has its own layout
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
name: string;
|
||||
layout: TerminalPanelContent | null;
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
isUnlocked: boolean;
|
||||
authToken: string | null;
|
||||
tabs: TerminalTab[];
|
||||
activeTabId: string | null;
|
||||
activeSessionId: string | null;
|
||||
maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none)
|
||||
defaultFontSize: number; // Default font size for new terminals
|
||||
defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code)
|
||||
screenReaderMode: boolean; // Enable screen reader accessibility mode
|
||||
fontFamily: string; // Font family for terminal text
|
||||
scrollbackLines: number; // Number of lines to keep in scrollback buffer
|
||||
lineHeight: number; // Line height multiplier for terminal text
|
||||
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
|
||||
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
|
||||
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
|
||||
}
|
||||
|
||||
// Persisted terminal layout - now includes sessionIds for reconnection
|
||||
// Used to restore terminal layout structure when switching projects
|
||||
export type PersistedTerminalPanel =
|
||||
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
|
||||
| { type: 'testRunner'; size?: number; sessionId?: string; worktreePath?: string }
|
||||
| {
|
||||
type: 'split';
|
||||
id?: string; // Optional for backwards compatibility with older persisted layouts
|
||||
direction: 'horizontal' | 'vertical';
|
||||
panels: PersistedTerminalPanel[];
|
||||
size?: number;
|
||||
};
|
||||
|
||||
// Helper to generate unique split IDs
|
||||
export const generateSplitId = () =>
|
||||
`split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
export interface PersistedTerminalTab {
|
||||
id: string;
|
||||
name: string;
|
||||
layout: PersistedTerminalPanel | null;
|
||||
}
|
||||
|
||||
export interface PersistedTerminalState {
|
||||
tabs: PersistedTerminalTab[];
|
||||
activeTabIndex: number; // Use index instead of ID since IDs are regenerated
|
||||
defaultFontSize: number;
|
||||
defaultRunScript?: string; // Optional to support existing persisted data
|
||||
screenReaderMode?: boolean; // Optional to support existing persisted data
|
||||
fontFamily?: string; // Optional to support existing persisted data
|
||||
scrollbackLines?: number; // Optional to support existing persisted data
|
||||
lineHeight?: number; // Optional to support existing persisted data
|
||||
}
|
||||
|
||||
// Persisted terminal settings - stored globally (not per-project)
|
||||
export interface PersistedTerminalSettings {
|
||||
defaultFontSize: number;
|
||||
defaultRunScript: string;
|
||||
screenReaderMode: boolean;
|
||||
fontFamily: string;
|
||||
scrollbackLines: number;
|
||||
lineHeight: number;
|
||||
maxSessions: number;
|
||||
openTerminalMode: 'newTab' | 'split';
|
||||
}
|
||||
228
apps/ui/src/store/types/ui-types.ts
Normal file
228
apps/ui/src/store/types/ui-types.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
export type ViewMode =
|
||||
| 'welcome'
|
||||
| 'setup'
|
||||
| 'spec'
|
||||
| 'board'
|
||||
| 'agent'
|
||||
| 'settings'
|
||||
| 'interview'
|
||||
| 'context'
|
||||
| 'running-agents'
|
||||
| 'terminal'
|
||||
| 'wiki'
|
||||
| 'ideation';
|
||||
|
||||
export type ThemeMode =
|
||||
// Special modes
|
||||
| 'system'
|
||||
// Dark themes
|
||||
| 'dark'
|
||||
| 'retro'
|
||||
| 'dracula'
|
||||
| 'nord'
|
||||
| 'monokai'
|
||||
| 'tokyonight'
|
||||
| 'solarized'
|
||||
| 'gruvbox'
|
||||
| 'catppuccin'
|
||||
| 'onedark'
|
||||
| 'synthwave'
|
||||
| 'red'
|
||||
| 'sunset'
|
||||
| 'gray'
|
||||
| 'forest'
|
||||
| 'ocean'
|
||||
| 'ember'
|
||||
| 'ayu-dark'
|
||||
| 'ayu-mirage'
|
||||
| 'matcha'
|
||||
// Light themes
|
||||
| 'light'
|
||||
| 'cream'
|
||||
| 'solarizedlight'
|
||||
| 'github'
|
||||
| 'paper'
|
||||
| 'rose'
|
||||
| 'mint'
|
||||
| 'lavender'
|
||||
| 'sand'
|
||||
| 'sky'
|
||||
| 'peach'
|
||||
| 'snow'
|
||||
| 'sepia'
|
||||
| 'gruvboxlight'
|
||||
| 'nordlight'
|
||||
| 'blossom'
|
||||
| 'ayu-light'
|
||||
| 'onelight'
|
||||
| 'bluloco'
|
||||
| 'feather';
|
||||
|
||||
export type BoardViewMode = 'kanban' | 'graph';
|
||||
|
||||
// Keyboard Shortcut with optional modifiers
|
||||
export interface ShortcutKey {
|
||||
key: string; // The main key (e.g., "K", "N", "1")
|
||||
shift?: boolean; // Shift key modifier
|
||||
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||
alt?: boolean; // Alt/Option key modifier
|
||||
}
|
||||
|
||||
// Board background settings
|
||||
export interface BackgroundSettings {
|
||||
imagePath: string | null;
|
||||
imageVersion?: number;
|
||||
cardOpacity: number;
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
hideScrollbar: boolean;
|
||||
}
|
||||
|
||||
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
|
||||
export interface KeyboardShortcuts {
|
||||
// Navigation shortcuts
|
||||
board: string;
|
||||
graph: string;
|
||||
agent: string;
|
||||
spec: string;
|
||||
context: string;
|
||||
memory: string;
|
||||
settings: string;
|
||||
projectSettings: string;
|
||||
terminal: string;
|
||||
ideation: string;
|
||||
notifications: string;
|
||||
githubIssues: string;
|
||||
githubPrs: string;
|
||||
|
||||
// UI shortcuts
|
||||
toggleSidebar: string;
|
||||
|
||||
// Action shortcuts
|
||||
addFeature: string;
|
||||
addContextFile: string;
|
||||
startNext: string;
|
||||
newSession: string;
|
||||
openProject: string;
|
||||
projectPicker: string;
|
||||
cyclePrevProject: string;
|
||||
cycleNextProject: string;
|
||||
|
||||
// Terminal shortcuts
|
||||
splitTerminalRight: string;
|
||||
splitTerminalDown: string;
|
||||
closeTerminal: string;
|
||||
newTerminalTab: string;
|
||||
}
|
||||
|
||||
// Import SidebarStyle from @automaker/types for UI slice
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* UI Slice State
|
||||
* Contains all UI-related state that is extracted into the UI slice.
|
||||
*/
|
||||
export interface UISliceState {
|
||||
// Core UI State
|
||||
currentView: ViewMode;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle;
|
||||
collapsedNavSections: Record<string, boolean>;
|
||||
mobileSidebarHidden: boolean;
|
||||
|
||||
// Theme State
|
||||
theme: ThemeMode;
|
||||
previewTheme: ThemeMode | null;
|
||||
|
||||
// Font State
|
||||
fontFamilySans: string | null;
|
||||
fontFamilyMono: string | null;
|
||||
|
||||
// Board UI State
|
||||
boardViewMode: BoardViewMode;
|
||||
boardBackgroundByProject: Record<string, BackgroundSettings>;
|
||||
|
||||
// Settings UI State
|
||||
keyboardShortcuts: KeyboardShortcuts;
|
||||
muteDoneSound: boolean;
|
||||
disableSplashScreen: boolean;
|
||||
showQueryDevtools: boolean;
|
||||
chatHistoryOpen: boolean;
|
||||
|
||||
// Panel Visibility State
|
||||
worktreePanelCollapsed: boolean;
|
||||
worktreePanelVisibleByProject: Record<string, boolean>;
|
||||
showInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
|
||||
|
||||
// File Picker UI State
|
||||
lastProjectDir: string;
|
||||
recentFolders: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Slice Actions
|
||||
* Contains all UI-related actions that are extracted into the UI slice.
|
||||
*/
|
||||
export interface UISliceActions {
|
||||
// View Actions
|
||||
setCurrentView: (view: ViewMode) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setSidebarStyle: (style: SidebarStyle) => void;
|
||||
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
|
||||
toggleNavSection: (sectionLabel: string) => void;
|
||||
toggleMobileSidebarHidden: () => void;
|
||||
setMobileSidebarHidden: (hidden: boolean) => void;
|
||||
|
||||
// Theme Actions (Pure UI only - project theme actions stay in main store)
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
getEffectiveTheme: () => ThemeMode;
|
||||
setPreviewTheme: (theme: ThemeMode | null) => void;
|
||||
|
||||
// Font Actions (Pure UI only - project font actions stay in main store)
|
||||
setFontSans: (fontFamily: string | null) => void;
|
||||
setFontMono: (fontFamily: string | null) => void;
|
||||
getEffectiveFontSans: () => string | null;
|
||||
getEffectiveFontMono: () => string | null;
|
||||
|
||||
// Board View Actions
|
||||
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
|
||||
setCardOpacity: (projectPath: string, opacity: number) => void;
|
||||
setColumnOpacity: (projectPath: string, opacity: number) => void;
|
||||
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
|
||||
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||
getBoardBackground: (projectPath: string) => BackgroundSettings;
|
||||
clearBoardBackground: (projectPath: string) => void;
|
||||
|
||||
// Settings UI Actions
|
||||
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
|
||||
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
|
||||
resetKeyboardShortcuts: () => void;
|
||||
setMuteDoneSound: (muted: boolean) => void;
|
||||
setDisableSplashScreen: (disabled: boolean) => void;
|
||||
setShowQueryDevtools: (show: boolean) => void;
|
||||
setChatHistoryOpen: (open: boolean) => void;
|
||||
toggleChatHistory: () => void;
|
||||
|
||||
// Panel Visibility Actions
|
||||
setWorktreePanelCollapsed: (collapsed: boolean) => void;
|
||||
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
|
||||
getWorktreePanelVisible: (projectPath: string) => boolean;
|
||||
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
|
||||
getShowInitScriptIndicator: (projectPath: string) => boolean;
|
||||
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
|
||||
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
|
||||
|
||||
// File Picker UI Actions
|
||||
setLastProjectDir: (dir: string) => void;
|
||||
setRecentFolders: (folders: string[]) => void;
|
||||
addRecentFolder: (folder: string) => void;
|
||||
}
|
||||
60
apps/ui/src/store/types/usage-types.ts
Normal file
60
apps/ui/src/store/types/usage-types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Claude Usage interface matching the server response
|
||||
export type ClaudeUsage = {
|
||||
sessionTokensUsed: number;
|
||||
sessionLimit: number;
|
||||
sessionPercentage: number;
|
||||
sessionResetTime: string;
|
||||
sessionResetText: string;
|
||||
|
||||
weeklyTokensUsed: number;
|
||||
weeklyLimit: number;
|
||||
weeklyPercentage: number;
|
||||
weeklyResetTime: string;
|
||||
weeklyResetText: string;
|
||||
|
||||
sonnetWeeklyTokensUsed: number;
|
||||
sonnetWeeklyPercentage: number;
|
||||
sonnetResetText: string;
|
||||
|
||||
costUsed: number | null;
|
||||
costLimit: number | null;
|
||||
costCurrency: string | null;
|
||||
|
||||
lastUpdated: string;
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
// Response type for Claude usage API (can be success or error)
|
||||
export type ClaudeUsageResponse = ClaudeUsage | { error: string; message?: string };
|
||||
|
||||
// Codex Usage types
|
||||
export type CodexPlanType =
|
||||
| 'free'
|
||||
| 'plus'
|
||||
| 'pro'
|
||||
| 'team'
|
||||
| 'business'
|
||||
| 'enterprise'
|
||||
| 'edu'
|
||||
| 'unknown';
|
||||
|
||||
export interface CodexRateLimitWindow {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
usedPercent: number; // Percentage used (0-100)
|
||||
windowDurationMins: number; // Duration in minutes
|
||||
resetsAt: number; // Unix timestamp in seconds
|
||||
}
|
||||
|
||||
export interface CodexUsage {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
// Response type for Codex usage API (can be success or error)
|
||||
export type CodexUsageResponse = CodexUsage | { error: string; message?: string };
|
||||
13
apps/ui/src/store/utils/index.ts
Normal file
13
apps/ui/src/store/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Theme utilities (PUBLIC)
|
||||
export {
|
||||
THEME_STORAGE_KEY,
|
||||
getStoredTheme,
|
||||
getStoredFontSans,
|
||||
getStoredFontMono,
|
||||
} from './theme-utils';
|
||||
|
||||
// Shortcut utilities (PUBLIC)
|
||||
export { parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS } from './shortcut-utils';
|
||||
|
||||
// Usage utilities (PUBLIC)
|
||||
export { isClaudeUsageAtLimit } from './usage-utils';
|
||||
117
apps/ui/src/store/utils/shortcut-utils.ts
Normal file
117
apps/ui/src/store/utils/shortcut-utils.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { ShortcutKey, KeyboardShortcuts } from '../types/ui-types';
|
||||
|
||||
// Helper to parse shortcut string to ShortcutKey object
|
||||
export function parseShortcut(shortcut: string | undefined | null): ShortcutKey {
|
||||
if (!shortcut) return { key: '' };
|
||||
const parts = shortcut.split('+').map((p) => p.trim());
|
||||
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
||||
|
||||
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const modifier = parts[i].toLowerCase();
|
||||
if (modifier === 'shift') result.shift = true;
|
||||
else if (
|
||||
modifier === 'cmd' ||
|
||||
modifier === 'ctrl' ||
|
||||
modifier === 'win' ||
|
||||
modifier === 'super' ||
|
||||
modifier === '⌘' ||
|
||||
modifier === '^' ||
|
||||
modifier === '⊞' ||
|
||||
modifier === '◆'
|
||||
)
|
||||
result.cmdCtrl = true;
|
||||
else if (modifier === 'alt' || modifier === 'opt' || modifier === 'option' || modifier === '⌥')
|
||||
result.alt = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to format ShortcutKey to display string
|
||||
export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string {
|
||||
if (!shortcut) return '';
|
||||
const parsed = parseShortcut(shortcut);
|
||||
const parts: string[] = [];
|
||||
|
||||
// Prefer User-Agent Client Hints when available; fall back to legacy
|
||||
const platform: 'darwin' | 'win32' | 'linux' = (() => {
|
||||
if (typeof navigator === 'undefined') return 'linux';
|
||||
|
||||
const uaPlatform = (
|
||||
navigator as Navigator & { userAgentData?: { platform?: string } }
|
||||
).userAgentData?.platform?.toLowerCase?.();
|
||||
const legacyPlatform = navigator.platform?.toLowerCase?.();
|
||||
const platformString = uaPlatform || legacyPlatform || '';
|
||||
|
||||
if (platformString.includes('mac')) return 'darwin';
|
||||
if (platformString.includes('win')) return 'win32';
|
||||
return 'linux';
|
||||
})();
|
||||
|
||||
// Primary modifier - OS-specific
|
||||
if (parsed.cmdCtrl) {
|
||||
if (forDisplay) {
|
||||
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
|
||||
} else {
|
||||
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
|
||||
}
|
||||
}
|
||||
|
||||
// Alt/Option
|
||||
if (parsed.alt) {
|
||||
parts.push(
|
||||
forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : platform === 'darwin' ? 'Opt' : 'Alt'
|
||||
);
|
||||
}
|
||||
|
||||
// Shift
|
||||
if (parsed.shift) {
|
||||
parts.push(forDisplay ? '⇧' : 'Shift');
|
||||
}
|
||||
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
|
||||
// Add spacing when displaying symbols
|
||||
return parts.join(forDisplay ? ' ' : '+');
|
||||
}
|
||||
|
||||
// Default keyboard shortcuts
|
||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
// Navigation
|
||||
board: 'K',
|
||||
graph: 'H',
|
||||
agent: 'A',
|
||||
spec: 'D',
|
||||
context: 'C',
|
||||
memory: 'Y',
|
||||
settings: 'S',
|
||||
projectSettings: 'Shift+S',
|
||||
terminal: 'T',
|
||||
ideation: 'I',
|
||||
notifications: 'X',
|
||||
githubIssues: 'G',
|
||||
githubPrs: 'R',
|
||||
|
||||
// UI
|
||||
toggleSidebar: '`',
|
||||
|
||||
// Actions
|
||||
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession)
|
||||
// This is intentional as they are context-specific and only active in their respective views
|
||||
addFeature: 'N', // Only active in board view
|
||||
addContextFile: 'N', // Only active in context view
|
||||
startNext: 'G', // Only active in board view
|
||||
newSession: 'N', // Only active in agent view
|
||||
openProject: 'O', // Global shortcut
|
||||
projectPicker: 'P', // Global shortcut
|
||||
cyclePrevProject: 'Q', // Global shortcut
|
||||
cycleNextProject: 'E', // Global shortcut
|
||||
|
||||
// Terminal shortcuts (only active in terminal view)
|
||||
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
|
||||
splitTerminalRight: 'Alt+D',
|
||||
splitTerminalDown: 'Alt+S',
|
||||
closeTerminal: 'Alt+W',
|
||||
newTerminalTab: 'Alt+T',
|
||||
};
|
||||
117
apps/ui/src/store/utils/theme-utils.ts
Normal file
117
apps/ui/src/store/utils/theme-utils.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { getItem, setItem, removeItem } from '@/lib/storage';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { ThemeMode } from '../types/ui-types';
|
||||
|
||||
// LocalStorage keys for persistence (fallback when server settings aren't available)
|
||||
export const THEME_STORAGE_KEY = 'automaker:theme';
|
||||
const FONT_SANS_STORAGE_KEY = 'automaker:font-sans';
|
||||
const FONT_MONO_STORAGE_KEY = 'automaker:font-mono';
|
||||
|
||||
/**
|
||||
* Get the theme from localStorage as a fallback
|
||||
* Used before server settings are loaded (e.g., on login/setup pages)
|
||||
*/
|
||||
export function getStoredTheme(): ThemeMode | null {
|
||||
const stored = getItem(THEME_STORAGE_KEY);
|
||||
if (stored) return stored as ThemeMode;
|
||||
|
||||
// Backwards compatibility: older versions stored theme inside the Zustand persist blob.
|
||||
// We intentionally keep reading it as a fallback so users don't get a "default theme flash"
|
||||
// on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet.
|
||||
try {
|
||||
const legacy = getItem('automaker-storage');
|
||||
if (!legacy) return null;
|
||||
interface LegacyStorageFormat {
|
||||
state?: { theme?: string };
|
||||
theme?: string;
|
||||
}
|
||||
const parsed = JSON.parse(legacy) as LegacyStorageFormat;
|
||||
const theme = parsed.state?.theme ?? parsed.theme;
|
||||
if (typeof theme === 'string' && theme.length > 0) {
|
||||
return theme as ThemeMode;
|
||||
}
|
||||
} catch {
|
||||
// Ignore legacy parse errors
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
export 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 projectFont === DEFAULT_FONT_VALUE ? null : projectFont;
|
||||
}
|
||||
// Invalid project font -> fall through to check global font
|
||||
}
|
||||
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
|
||||
*/
|
||||
export function saveThemeToStorage(theme: ThemeMode): void {
|
||||
setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fonts from localStorage as a fallback
|
||||
* Used before server settings are loaded (e.g., on login/setup pages)
|
||||
*/
|
||||
export function getStoredFontSans(): string | null {
|
||||
return getItem(FONT_SANS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredFontMono(): string | null {
|
||||
return getItem(FONT_MONO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save fonts to localStorage for immediate persistence
|
||||
* This is used as a fallback when server settings can't be loaded
|
||||
*/
|
||||
export function saveFontSansToStorage(fontFamily: string | null): void {
|
||||
if (fontFamily) {
|
||||
setItem(FONT_SANS_STORAGE_KEY, fontFamily);
|
||||
} else {
|
||||
// Remove from storage if null (using default)
|
||||
removeItem(FONT_SANS_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFontMonoToStorage(fontFamily: string | null): void {
|
||||
if (fontFamily) {
|
||||
setItem(FONT_MONO_STORAGE_KEY, fontFamily);
|
||||
} else {
|
||||
// Remove from storage if null (using default)
|
||||
removeItem(FONT_MONO_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistEffectiveThemeForProject(
|
||||
project: Project | null,
|
||||
fallbackTheme: ThemeMode
|
||||
): void {
|
||||
const projectTheme = project?.theme as ThemeMode | undefined;
|
||||
const themeToStore = projectTheme ?? fallbackTheme;
|
||||
saveThemeToStorage(themeToStore);
|
||||
}
|
||||
34
apps/ui/src/store/utils/usage-utils.ts
Normal file
34
apps/ui/src/store/utils/usage-utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ClaudeUsage } from '../types/usage-types';
|
||||
|
||||
/**
|
||||
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
|
||||
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
|
||||
*/
|
||||
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
|
||||
if (!claudeUsage) {
|
||||
// No usage data available - don't block
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session limit (5-hour window)
|
||||
if (claudeUsage.sessionPercentage >= 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check weekly limit
|
||||
if (claudeUsage.weeklyPercentage >= 100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check cost limit (if configured)
|
||||
if (
|
||||
claudeUsage.costLimit !== null &&
|
||||
claudeUsage.costLimit > 0 &&
|
||||
claudeUsage.costUsed !== null &&
|
||||
claudeUsage.costUsed >= claudeUsage.costLimit
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -965,8 +965,20 @@ export const DEFAULT_PLAN_REVISION_TEMPLATE = `The user has requested revisions
|
||||
|
||||
## Instructions
|
||||
Please regenerate the specification incorporating the user's feedback.
|
||||
Keep the same format with the \`\`\`tasks block for task definitions.
|
||||
After generating the revised spec, output:
|
||||
**Current planning mode: {{planningMode}}**
|
||||
|
||||
**CRITICAL REQUIREMENT**: Your revised specification MUST include a \`\`\`tasks code block containing task definitions in the EXACT format shown below. This is MANDATORY - without the tasks block, the system cannot track or execute tasks properly.
|
||||
|
||||
### Required Task Format
|
||||
{{taskFormatExample}}
|
||||
|
||||
**IMPORTANT**:
|
||||
1. The \`\`\`tasks block must appear in your response
|
||||
2. Each task MUST start with "- [ ] T###:" where ### is a sequential number (T001, T002, T003, etc.)
|
||||
3. Each task MUST include "| File:" followed by the primary file path
|
||||
4. Tasks should be ordered by dependencies (foundational tasks first)
|
||||
|
||||
After generating the revised spec with the tasks block, output:
|
||||
"[SPEC_GENERATED] Please review the revised specification above."`;
|
||||
|
||||
export const DEFAULT_CONTINUATION_AFTER_APPROVAL_TEMPLATE = `The plan/specification has been approved. Now implement it.
|
||||
|
||||
Reference in New Issue
Block a user