Compare commits

...

11 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
19 changed files with 813 additions and 392 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

@@ -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,7 +1,7 @@
import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { saveProjects, saveTrashedProjects } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
// Note: setItem/getItem moved to ./utils/theme-utils.ts
@@ -60,6 +60,8 @@ import {
type ShortcutKey,
type KeyboardShortcuts,
type BackgroundSettings,
type UISliceState,
type UISliceActions,
// Settings types
type ApiKeys,
// Chat types
@@ -109,16 +111,13 @@ import {
} from './utils';
// Import default values from modular defaults files
import { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults';
import { defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults';
// Import UI slice
import { createUISlice, initialUIState } from './slices';
// Import internal theme utils (not re-exported publicly)
import {
getEffectiveFont,
saveThemeToStorage,
saveFontSansToStorage,
saveFontMonoToStorage,
persistEffectiveThemeForProject,
} from './utils/theme-utils';
import { persistEffectiveThemeForProject } from './utils/theme-utils';
const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
@@ -146,6 +145,8 @@ export type {
ShortcutKey,
KeyboardShortcuts,
BackgroundSettings,
UISliceState,
UISliceActions,
ApiKeys,
ImageAttachment,
TextFileAttachment,
@@ -213,56 +214,72 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
// - defaultTerminalState (./defaults/terminal-defaults.ts)
const initialState: AppState = {
// Spread UI slice state first
...initialUIState,
// Project state
projects: [],
currentProject: null,
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true,
sidebarStyle: 'unified',
collapsedNavSections: {},
mobileSidebarHidden: false,
// Agent Session state
lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(),
fontFamilyMono: getStoredFontMono(),
// Features/Kanban
features: [],
// App spec
appSpec: '',
// IPC status
ipcConnected: false,
// API Keys
apiKeys: {
anthropic: '',
google: '',
openai: '',
},
// Chat Sessions
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
// Auto Mode
autoModeByWorktree: {},
autoModeActivityLog: [],
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
boardViewMode: 'kanban',
// Feature Default Settings
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
enableAiCommitMessages: true,
planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false,
// Worktree Settings
useWorktrees: true,
currentWorktreeByProject: {},
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false,
disableSplashScreen: false,
// Server Settings
serverLogLevel: 'info',
enableRequestLogging: true,
showQueryDevtools: true,
// Model Settings
enhancementModel: 'claude-sonnet',
validationModel: 'claude-opus',
phaseModels: DEFAULT_PHASE_MODELS,
favoriteModels: [],
// Cursor CLI Settings
enabledCursorModels: getAllCursorModelIds(),
cursorDefaultModel: 'cursor-auto',
// Codex CLI Settings
enabledCodexModels: getAllCodexModelIds(),
codexDefaultModel: 'codex-gpt-5.2-codex',
codexAutoLoadAgents: false,
@@ -270,6 +287,8 @@ const initialState: AppState = {
codexApprovalPolicy: 'on-request',
codexEnableWebSearch: false,
codexEnableImages: false,
// OpenCode CLI Settings
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
dynamicOpencodeModels: [],
@@ -279,61 +298,101 @@ const initialState: AppState = {
opencodeModelsError: null,
opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null,
// Gemini CLI Settings
enabledGeminiModels: getAllGeminiModelIds(),
geminiDefaultModel: DEFAULT_GEMINI_MODEL,
// Copilot SDK Settings
enabledCopilotModels: getAllCopilotModelIds(),
copilotDefaultModel: DEFAULT_COPILOT_MODEL,
// Provider Settings
disabledProviders: [],
// Claude Agent SDK Settings
autoLoadClaudeMd: false,
skipSandboxWarning: false,
// MCP Servers
mcpServers: [],
// Editor Configuration
defaultEditorCommand: null,
// Terminal Configuration
defaultTerminalId: null,
// Skills Configuration
enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Subagents Configuration
enableSubagents: true,
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Prompt Customization
promptCustomization: {},
// Event Hooks
eventHooks: [],
// Claude-Compatible Providers
claudeCompatibleProviders: [],
claudeApiProfiles: [],
activeClaudeApiProfileId: null,
// Project Analysis
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null,
// Terminal state
terminalState: defaultTerminalState,
terminalLayoutByProject: {},
// Spec Creation
specCreatingForProject: null,
// Planning
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
pendingPlanApproval: null,
// Claude Usage Tracking
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
// Codex Usage Tracking
codexUsage: null,
codexUsageLastUpdated: null,
// Codex Models
codexModels: [],
codexModelsLoading: false,
codexModelsError: null,
codexModelsLastFetched: null,
codexModelsLastFailedAt: null,
// Pipeline Configuration
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
// Project-specific Worktree Settings
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
// Init Script State
initScriptState: {},
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
export const useAppStore = create<AppState & AppActions>()((set, get, store) => ({
// Spread initial non-UI state
...initialState,
// Spread UI slice (includes UI state and actions)
...createUISlice(set, get, store),
// Project actions
setProjects: (projects) => set({ projects }),
@@ -360,7 +419,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const trashedProject: TrashedProject = {
...project,
trashedAt: Date.now(),
trashedAt: new Date().toISOString(),
};
set((state) => ({
@@ -369,12 +428,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveProjects(get().projects);
saveTrashedProjects(get().trashedProjects);
},
restoreTrashedProject: (projectId: string) => {
@@ -390,12 +446,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveProjects(get().projects);
saveTrashedProjects(get().trashedProjects);
},
deleteTrashedProject: (projectId: string) => {
@@ -403,21 +456,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
// Persist to storage
saveTrashedProjects(get().trashedProjects);
},
emptyTrash: () => {
set({ trashedProjects: [] });
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects([]);
}
// Persist to storage
saveTrashedProjects([]);
},
setCurrentProject: (project) => {
@@ -474,14 +521,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
get().addProject(newProject);
get().setCurrentProject(newProject);
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
// Small delay to ensure state is updated before persisting
setTimeout(() => {
electronAPI.projects.setProjects(get().projects);
}, 0);
}
// Persist to storage (small delay to ensure state is updated)
setTimeout(() => {
saveProjects(get().projects);
}, 0);
return newProject;
},
@@ -564,11 +607,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectIcon: (projectId: string, icon: string | null) => {
@@ -576,27 +616,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, icon: icon ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, icon: icon ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, customIcon: customIconPath ?? undefined } : p
p.id === projectId ? { ...p, customIconPath: customIconPath ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, customIconPath: customIconPath ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectName: (projectId: string, name: string) => {
@@ -609,35 +653,13 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarStyle: (style) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () =>
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
// View actions - provided by UI slice
// Theme actions
setTheme: (theme) => {
set({ theme });
saveThemeToStorage(theme);
},
// Theme actions (setTheme, getEffectiveTheme, setPreviewTheme provided by UI slice)
setProjectTheme: (projectId: string, theme: ThemeMode | null) => {
set((state) => ({
projects: state.projects.map((p) =>
@@ -659,76 +681,40 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
);
}
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
getEffectiveTheme: () => {
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) => set({ previewTheme: theme }),
// Font actions
setFontSans: (fontFamily) => {
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},
// Font actions (setFontSans, setFontMono, getEffectiveFontSans, getEffectiveFontMono provided by UI slice)
setProjectFontSans: (projectId: string, fontFamily: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontSans: fontFamily ?? undefined } : p
p.id === projectId ? { ...p, fontFamilySans: fontFamily ?? undefined } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, fontSans: fontFamily ?? undefined }
? { ...state.currentProject, fontFamilySans: fontFamily ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontMono: fontFamily ?? undefined } : p
p.id === projectId ? { ...p, fontFamilyMono: fontFamily ?? undefined } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, fontMono: fontFamily ?? undefined }
? { ...state.currentProject, fontFamilyMono: fontFamily ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontSans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
// Persist to storage
saveProjects(get().projects);
},
// Claude API Profile actions (per-project override)
@@ -744,11 +730,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Project Phase Model Overrides
@@ -781,11 +764,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
clearAllProjectPhaseModelOverrides: (projectId: string) => {
@@ -804,11 +784,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Project Default Feature Model Override
@@ -830,11 +807,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
// Persist to storage
saveProjects(get().projects);
},
// Feature actions
@@ -845,7 +819,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature: Feature = { ...feature, id };
const newFeature = { ...feature, id } as Feature;
set((state) => ({ features: [...state.features, newFeature] }));
return newFeature;
},
@@ -925,8 +899,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
})),
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }),
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
// setChatHistoryOpen and toggleChatHistory - provided by UI slice
// Auto Mode actions (per-worktree)
getWorktreeKey: (projectId: string, branchName: string | null) =>
@@ -1057,8 +1030,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}));
},
// Kanban Card Settings actions
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
// Kanban Card Settings actions - setBoardViewMode provided by UI slice
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -1133,29 +1105,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return mainWorktree?.branch ?? null;
},
// Keyboard Shortcuts actions
setKeyboardShortcut: (key, value) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
})),
setKeyboardShortcuts: (shortcuts) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
})),
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
// Keyboard Shortcuts actions - provided by UI slice
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Audio Settings actions - setMuteDoneSound provided by UI slice
// Splash Screen actions
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Splash Screen actions - setDisableSplashScreen provided by UI slice
// Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
// Developer Tools actions
setShowQueryDevtools: (show) => set({ showQueryDevtools: show }),
// Developer Tools actions - setShowQueryDevtools provided by UI slice
// Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }),
@@ -1525,96 +1485,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
// Board Background actions
setBoardBackground: (projectPath, imagePath) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
imagePath,
imageVersion: Date.now(), // Bust cache on image change
},
},
})),
setCardOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardOpacity: opacity,
},
},
})),
setColumnOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnOpacity: opacity,
},
},
})),
setColumnBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnBorderEnabled: enabled,
},
},
})),
getBoardBackground: (projectPath) =>
get().boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings,
setCardGlassmorphism: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardGlassmorphism: enabled,
},
},
})),
setCardBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderEnabled: enabled,
},
},
})),
setCardBorderOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderOpacity: opacity,
},
},
})),
setHideScrollbar: (projectPath, hide) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
hideScrollbar: hide,
},
},
})),
clearBoardBackground: (projectPath) =>
set((state) => {
const newBackgrounds = { ...state.boardBackgroundByProject };
delete newBackgrounds[projectPath];
return { boardBackgroundByProject: newBackgrounds };
}),
// Board Background actions - provided by UI slice
// Terminal actions
setTerminalUnlocked: (unlocked, token) =>
@@ -2364,27 +2235,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
};
}),
// Worktree Panel Visibility actions
setWorktreePanelVisible: (projectPath, visible) =>
set((state) => ({
worktreePanelVisibleByProject: {
...state.worktreePanelVisibleByProject,
[projectPath]: visible,
},
})),
getWorktreePanelVisible: (projectPath) =>
get().worktreePanelVisibleByProject[projectPath] ?? true,
// Worktree Panel Visibility actions - provided by UI slice
// Init Script Indicator Visibility actions
setShowInitScriptIndicator: (projectPath, visible) =>
set((state) => ({
showInitScriptIndicatorByProject: {
...state.showInitScriptIndicatorByProject,
[projectPath]: visible,
},
})),
getShowInitScriptIndicator: (projectPath) =>
get().showInitScriptIndicatorByProject[projectPath] ?? true,
// Init Script Indicator Visibility actions - provided by UI slice
// Default Delete Branch actions
setDefaultDeleteBranch: (projectPath, deleteBranch) =>
@@ -2396,16 +2249,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})),
getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false,
// Auto-dismiss Init Script Indicator actions
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) =>
set((state) => ({
autoDismissInitScriptIndicatorByProject: {
...state.autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
})),
getAutoDismissInitScriptIndicator: (projectPath) =>
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
// Auto-dismiss Init Script Indicator actions - provided by UI slice
// Use Worktrees Override actions
setProjectUseWorktrees: (projectPath, useWorktrees) =>
@@ -2421,15 +2265,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return projectOverride !== undefined ? projectOverride : get().useWorktrees;
},
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
setRecentFolders: (folders) => set({ recentFolders: folders }),
addRecentFolder: (folder) =>
set((state) => {
const filtered = state.recentFolders.filter((f) => f !== folder);
return { recentFolders: [folder, ...filtered].slice(0, 10) };
}),
// UI State actions - provided by UI slice
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }),
@@ -2471,8 +2307,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/codex/models');
const data = response.data as {
const data = await httpApi.get<{
success: boolean;
models?: Array<{
id: string;
@@ -2484,7 +2319,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
isDefault: boolean;
}>;
error?: string;
};
}>('/api/codex/models');
if (data.success && data.models) {
set({
@@ -2542,8 +2377,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/opencode/models');
const data = response.data as {
const data = await httpApi.get<{
success: boolean;
models?: ModelDefinition[];
providers?: Array<{
@@ -2553,7 +2387,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
authMethod?: string;
}>;
error?: string;
};
}>('/api/setup/opencode/models');
if (data.success && data.models) {
// Filter out Bedrock models

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

@@ -117,3 +117,112 @@ export interface KeyboardShortcuts {
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

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