Compare commits

...

24 Commits

Author SHA1 Message Date
Kacper
6e2f277f63 Merge v0.14.0rc into refactor/store-ui-slice
Resolve merge conflict in app-store.ts by keeping UI slice implementation
of getEffectiveFontSans/getEffectiveFontMono (already provided by ui-slice.ts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:48:41 +01:00
Shirone
ef3f8de33b Merge pull request #715 from OG-Ken/fix/opencode-dynamic-models-404-endpoint
fix: Correct OpenCode dynamic models API endpoint URL
2026-01-27 12:02:33 +00:00
Ken Lopez
d379bf412a fix: Correct OpenCode dynamic models API endpoint URL
The fetchOpencodeModels function was calling '/api/opencode/models' which
returns 404. Changed to '/api/setup/opencode/models' which correctly
returns the dynamic models.

This fixes an issue where enabled OpenCode dynamic models (e.g., local
Ollama models) were not appearing in the Model Defaults dropdown selectors
despite being visible and enabled in the OpenCode Settings page.
2026-01-27 03:06:28 -05:00
Shirone
cf35ca8650 Merge pull request #714 from AutoMaker-Org/feature/bug-request-changes-on-plan-mode-is-not-proceedin-8xpd
refactor(auto-mode): Enhance revision prompt customization
2026-01-26 23:36:30 +00:00
Shirone
4f1555f196 feat(event-history): Replace alert with toast notifications for event replay results
Update the EventHistoryView component to use toast notifications instead of alert dialogs for displaying event replay results, enhancing user experience and providing clearer feedback on success and failure states.
2026-01-27 00:29:34 +01:00
Shirone
5aace0ce0f fix(event-hook): Update featureName assignment to prioritize loaded feature title over payload 2026-01-27 00:25:36 +01:00
Shirone
e439d8a632 fix(routes): Update feature creation event to use title instead of name
Change the feature creation event to emit 'Untitled Feature' when the title is not provided, improving clarity in event handling.
2026-01-27 00:25:16 +01:00
Shirone
b7c6b8bfc6 feat(ui): Show project name in classic sidebar layout
Add project name display at the top of the navigation for the classic
(discord) sidebar style, which previously didn't show the project name
anywhere. Shows the project icon (custom or Lucide) and name with a
separator below.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:15:38 +01:00
Shirone
a60904bd51 fix(ui,server): Fix project icon updates and image upload issues
- Fix setProjectCustomIcon using wrong property name (customIcon -> customIconPath)
- Add currentProject state update to setProjectIcon and setProjectCustomIcon
- Fix data URL regex to handle all formats (e.g., charset=utf-8 in GIFs)
- Increase project icon size limit from 2MB to 5MB for animated GIFs
- Add toast notifications for upload validation errors
- Add image error fallback to folder icon in project switcher
- Make HttpApiClient get/put methods public for store access
- Fix TypeScript errors in app-store.ts (trashedAt type, font properties)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:09:55 +01:00
Kacper
d7c3337330 refactor(auto-mode): Enhance revision prompt customization and task format validation
- Updated the revision prompt generation to utilize a customizable template, allowing for dynamic insertion of plan version, previous plan content, user feedback, and task format examples.
- Added validation to ensure the presence of a tasks block in the revised specification, with clear instructions on the required format to prevent execution issues.
- Introduced logging for scenarios where no tasks are found in the revised plan, warning about potential fallback to single-agent execution.
2026-01-26 19:53:07 +01:00
Shirone
79236ba16e refactor(store): Extract UI slice from app-store.ts
- Extract UI-related state and actions into store/slices/ui-slice.ts
- Add UISliceState and UISliceActions interfaces to store/types/ui-types.ts
- First implementation of Zustand slice pattern in the codebase
- Fix pre-existing bug: fontSans/fontMono -> fontFamilySans/fontFamilyMono
- Maintain backward compatibility through re-exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:03:58 +01:00
Shirone
c848306e4c Merge pull request #709 from AutoMaker-Org/refactor/store-defaults
refactor(store): Extract default values into store/defaults/
2026-01-25 22:39:59 +00:00
Shirone
f0042312d0 refactor(store): Extract default values into store/defaults/
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:32:29 +01:00
Shirone
e876d177b8 Merge pull request #708 from AutoMaker-Org/refactor/store-utils
refactor(store): Extract utility functions into store/utils/
2026-01-25 22:18:10 +00:00
Shirone
8caec15199 refactor(store): Extract utility functions into store/utils/
Move pure utility functions from app-store.ts and type files into
dedicated utils modules for better separation of concerns:

- theme-utils.ts: Theme and font storage utilities
- shortcut-utils.ts: Keyboard shortcut parsing/formatting
- usage-utils.ts: Usage limit checking

All utilities are re-exported from store/utils/index.ts and
app-store.ts maintains backward compatibility for existing imports.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:03:52 +01:00
Shirone
7fe9aacb09 Merge pull request #706 from AutoMaker-Org/refactor/store-types
refactor(store): Extract types from app-store.ts into modular type files
2026-01-25 21:36:40 +00:00
Shirone
f55c985634 refactor(types): Make FeatureImage extend ImageAttachment
Address Gemini review feedback - reduce code duplication by having
FeatureImage extend ImageAttachment instead of duplicating properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:32:46 +01:00
Shirone
38e8a4c4ea refactor(store): Extract types from app-store.ts into modular type files
- Create store/types/ directory with 8 modular type files:
  - usage-types.ts: ClaudeUsage, CodexUsage, isClaudeUsageAtLimit
  - ui-types.ts: ViewMode, ThemeMode, KeyboardShortcuts, etc.
  - settings-types.ts: ApiKeys
  - chat-types.ts: ChatMessage, ChatSession, FeatureImage
  - terminal-types.ts: TerminalState, TerminalTab, etc.
  - project-types.ts: Feature, FileTreeNode, ProjectAnalysis
  - state-types.ts: AppState, AppActions interfaces
  - index.ts: Re-exports all types

- Update electron.ts to import from store/types/usage-types
  (breaks circular dependency between electron.ts and app-store.ts)

- Update app-store.ts to import and re-export types for backward
  compatibility - existing imports from @/store/app-store continue
  to work

This is PR 1 of the app-store refactoring plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:20:27 +01:00
Shirone
f3ce5ce8ab Merge pull request #704 from AutoMaker-Org/refactor/electron-main-process
refactor: Modularize Electron main process into single-responsibility components
2026-01-25 20:10:39 +00:00
Shirone
99de7813c9 fix: Apply titleBarStyle only on macOS
titleBarStyle: 'hiddenInset' is a macOS-only option. Use conditional
spread to only apply it when process.platform === 'darwin', ensuring
Windows and Linux get consistent default styling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:05:07 +01:00
Shirone
2de3ae69d4 fix: Address CodeRabbit security and robustness review comments
- Guard against NaN ports from non-numeric env variables in constants.ts
- Validate IPC sender before returning API key to prevent leaking to
  untrusted senders (webviews, additional windows)
- Filter dialog properties to maintain file-only intent and prevent
  renderer from requesting directories via OPEN_FILE
- Fix Windows VS Code URL paths by ensuring leading slash after 'file'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:02:53 +01:00
Shirone
0b4e9573ed refactor: Simplify tsx path lookup and remove redundant try-catch
Address review feedback:
- Simplify tsx CLI path lookup by extracting path variables and
  reducing nested try-catch blocks
- Remove redundant try-catch around electronAppExists check in
  production server path validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:54:10 +01:00
Shirone
d7ad87bd1b fix: Correct __dirname paths for Vite bundled electron modules
Vite bundles all electron modules into a single main.js file,
so __dirname remains apps/ui/dist-electron regardless of source
file location. Updated path comments to clarify this behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:49:53 +01:00
Shirone
615823652c refactor: Modularize Electron main process into single-responsibility components
Extract the monolithic main.ts (~1000 lines) into focused modules:

- electron/constants.ts - Window sizing, port defaults, filenames
- electron/state.ts - Shared state container
- electron/utils/ - Port availability 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 with shared channel constants

Benefits:
- Improved testability with isolated modules
- Better discoverability and maintainability
- Single source of truth for IPC channels (used by both main and preload)
- Clear separation of concerns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 20:43:08 +01:00
54 changed files with 5159 additions and 4620 deletions

View File

@@ -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,
});
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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',

View File

@@ -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,

View File

@@ -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>

View File

@@ -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',

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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>
);
});

View File

@@ -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>

View File

@@ -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);
}

View 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;
}

View 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';

View 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();
});
}

View 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;
});
}

View 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;

View 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;
});
}

View 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();
}

View 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';
});
}

View 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 };
}
}
);
}

View 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);
});
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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,
};

View 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;
}

View 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}`);
}

View 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');
}

View 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),
};
}

View File

@@ -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}`, {

View File

@@ -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();
}

View File

@@ -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

View 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,
};

View 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;

View File

@@ -0,0 +1,3 @@
export { defaultBackgroundSettings } from './background-settings';
export { defaultTerminalState } from './terminal-defaults';
export { MAX_INIT_OUTPUT_LINES } from './constants';

View 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',
};

View File

@@ -0,0 +1 @@
export { createUISlice, initialUIState, type UISlice } from './ui-slice';

View 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) };
}),
});

View 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)
}

View 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';

View 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;
}

View File

@@ -0,0 +1,5 @@
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}

View 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;
}

View 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';
}

View 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;
}

View 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 };

View 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';

View 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',
};

View 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);
}

View 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;
}

View File

@@ -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.