Compare commits

..

2 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
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
18 changed files with 586 additions and 370 deletions

View File

@@ -118,7 +118,6 @@ RUN curl -fsSL https://opencode.ai/install | bash && \
echo "=== Checking OpenCode CLI installation ===" && \ echo "=== Checking OpenCode CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \ ls -la /home/automaker/.local/bin/ && \
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)" (which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
USER root USER root
# Add PATH to profile so it's available in all interactive shells (for login shells) # Add PATH to profile so it's available in all interactive shells (for login shells)
@@ -148,15 +147,6 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
# Copy node_modules (includes symlinks to libs) # Copy node_modules (includes symlinks to libs)
COPY --from=server-builder /app/node_modules ./node_modules COPY --from=server-builder /app/node_modules ./node_modules
# Install Playwright Chromium browser for AI agent verification tests
# This adds ~300MB to the image but enables automated testing mode out of the box
# Using the locally installed playwright ensures we use the pinned version from package-lock.json
USER automaker
RUN ./node_modules/.bin/playwright install chromium && \
echo "=== Playwright Chromium installed ===" && \
ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed"
USER root
# Create data and projects directories # Create data and projects directories
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects RUN mkdir -p /data /projects && chown automaker:automaker /data /projects

View File

@@ -338,42 +338,6 @@ services:
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build. The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
##### Playwright for Automated Testing
The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
**No additional setup required** - Playwright verification works out of the box.
#### Optional: Persist browsers for manual updates
By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
```bash
# After adding the volume mount for the first time
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
Add this to your `docker-compose.override.yml`:
```yaml
services:
server:
volumes:
- playwright-cache:/home/automaker/.cache/ms-playwright
volumes:
playwright-cache:
name: automaker-playwright-cache
```
**Updating browsers manually:**
```bash
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
### Testing ### Testing
#### End-to-End Tests (Playwright) #### End-to-End Tests (Playwright)

View File

@@ -43,14 +43,10 @@ export function createInitGitHandler() {
// .git doesn't exist, continue with initialization // .git doesn't exist, continue with initialization
} }
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020) // Initialize git and create an initial empty commit
// and create an initial empty commit await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath, cwd: projectPath,
} });
);
res.json({ res.json({
success: true, success: true,

View File

@@ -20,8 +20,8 @@ export interface TestRepo {
export async function createTestGitRepo(): Promise<TestRepo> { export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-')); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo with 'main' as the default branch (matching GitHub's standard) // Initialize git repo
await execAsync('git init --initial-branch=main', { cwd: tmpDir }); await execAsync('git init', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config // Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it // These env vars override git config without modifying it
@@ -38,6 +38,9 @@ export async function createTestGitRepo(): Promise<TestRepo> {
await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });
return { return {
path: tmpDir, path: tmpDir,
cleanup: async () => { cleanup: async () => {

View File

@@ -14,8 +14,7 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() { async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
// Initialize with 'main' as the default branch (matching GitHub's standard) await execAsync('git init', { cwd: repoPath });
await execAsync('git init --initial-branch=main', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead // Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config // to avoid affecting user's git config
// Intentionally skip creating an initial commit // Intentionally skip creating an initial commit

View File

@@ -30,16 +30,11 @@ import net from 'net';
describe('dev-server-service.ts', () => { describe('dev-server-service.ts', () => {
let testDir: string; let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.resetModules(); vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`); testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true }); await fs.mkdir(testDir, { recursive: true });
@@ -61,13 +56,6 @@ describe('dev-server-service.ts', () => {
}); });
afterEach(async () => { afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try { try {
await fs.rm(testDir, { recursive: true, force: true }); await fs.rm(testDir, { recursive: true, force: true });
} catch { } catch {

View File

@@ -7,8 +7,8 @@
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs'; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -112,29 +112,6 @@ execSync('npm install --omit=dev', {
}, },
}); });
// Step 6b: Replace symlinks for local packages with real copies
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
console.log('🔗 Replacing symlinks with real directory copies...');
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
try {
// lstatSync does not follow symlinks, allowing us to check for broken ones
if (lstatSync(nmPkgPath).isSymbolicLink()) {
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
rmSync(nmPkgPath);
cpSync(realPath, nmPkgPath, { recursive: true });
console.log(` ✓ Replaced symlink: ${pkgName}`);
}
} catch (error) {
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
if (error.code !== 'ENOENT') {
throw error;
}
}
}
// Step 7: Rebuild native modules for current architecture // Step 7: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings // This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...'); console.log('🔨 Rebuilding native modules for current architecture...');

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn, isMac } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection'; import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
@@ -11,12 +11,9 @@ import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation } from '@/components/layout/sidebar/hooks'; import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
MACOS_ELECTRON_TOP_PADDING_CLASS,
SIDEBAR_FEATURE_FLAGS,
} from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { getElectronAPI, isElectron } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -282,12 +279,7 @@ export function ProjectSwitcher() {
data-testid="project-switcher" data-testid="project-switcher"
> >
{/* Automaker Logo and Version */} {/* Automaker Logo and Version */}
<div <div className="flex flex-col items-center pt-3 pb-2 px-2">
className={cn(
'flex flex-col items-center pb-2 px-2',
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
)}
>
<button <button
onClick={() => navigate({ to: '/dashboard' })} onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5" className="group flex flex-col items-center gap-0.5"

View File

@@ -6,7 +6,6 @@ import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { import {
@@ -90,7 +89,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2', 'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS isMac && isElectron() && 'pt-[10px]'
)} )}
> >
<Tooltip> <Tooltip>
@@ -241,7 +240,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col relative px-3 pt-3 pb-2', 'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS isMac && isElectron() && 'pt-[10px]'
)} )}
> >
{/* Header with logo and project dropdown */} {/* Header with logo and project dropdown */}

View File

@@ -3,9 +3,7 @@ import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react'; import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isElectron } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { formatShortcut, useAppStore } from '@/store/app-store'; import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { NavSection } from '../types'; import type { NavSection } from '../types';
@@ -119,12 +117,7 @@ export function SidebarNavigation({
className={cn( className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2', 'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header // Add top padding in discord mode since there's no header
// Extra padding for macOS Electron to avoid traffic light overlap sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
sidebarStyle === 'discord'
? isMac && isElectron()
? MACOS_ELECTRON_TOP_PADDING_CLASS
: 'pt-3'
: 'mt-1'
)} )}
> >
{/* Project name display for classic/discord mode */} {/* Project name display for classic/discord mode */}

View File

@@ -1,11 +1,5 @@
import { darkThemes, lightThemes } from '@/config/theme-options'; import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
* This padding is applied conditionally when running on macOS in Electron.
*/
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
/** /**
* Shared constants for theme submenu positioning and layout. * Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components * Used across project-context-menu and project-selector-with-options components

View File

@@ -116,8 +116,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
}, },
copilot: { copilot: {
viewBox: '0 0 98 96', viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark (theme-aware via currentColor) // Official GitHub Octocat logo mark
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z', path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
fill: '#ffffff',
}, },
}; };

View File

@@ -60,6 +60,8 @@ import {
type ShortcutKey, type ShortcutKey,
type KeyboardShortcuts, type KeyboardShortcuts,
type BackgroundSettings, type BackgroundSettings,
type UISliceState,
type UISliceActions,
// Settings types // Settings types
type ApiKeys, type ApiKeys,
// Chat types // Chat types
@@ -109,16 +111,13 @@ import {
} from './utils'; } from './utils';
// Import default values from modular defaults files // 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 internal theme utils (not re-exported publicly)
import { import { persistEffectiveThemeForProject } from './utils/theme-utils';
getEffectiveFont,
saveThemeToStorage,
saveFontSansToStorage,
saveFontMonoToStorage,
persistEffectiveThemeForProject,
} from './utils/theme-utils';
const logger = createLogger('AppStore'); const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
@@ -146,6 +145,8 @@ export type {
ShortcutKey, ShortcutKey,
KeyboardShortcuts, KeyboardShortcuts,
BackgroundSettings, BackgroundSettings,
UISliceState,
UISliceActions,
ApiKeys, ApiKeys,
ImageAttachment, ImageAttachment,
TextFileAttachment, TextFileAttachment,
@@ -213,56 +214,72 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
// - defaultTerminalState (./defaults/terminal-defaults.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts)
const initialState: AppState = { const initialState: AppState = {
// Spread UI slice state first
...initialUIState,
// Project state
projects: [], projects: [],
currentProject: null, currentProject: null,
trashedProjects: [], trashedProjects: [],
projectHistory: [], projectHistory: [],
projectHistoryIndex: -1, projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true, // Agent Session state
sidebarStyle: 'unified',
collapsedNavSections: {},
mobileSidebarHidden: false,
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(), // Features/Kanban
fontFamilyMono: getStoredFontMono(),
features: [], features: [],
// App spec
appSpec: '', appSpec: '',
// IPC status
ipcConnected: false, ipcConnected: false,
// API Keys
apiKeys: { apiKeys: {
anthropic: '', anthropic: '',
google: '', google: '',
openai: '', openai: '',
}, },
// Chat Sessions
chatSessions: [], chatSessions: [],
currentChatSession: null, currentChatSession: null,
chatHistoryOpen: false,
// Auto Mode
autoModeByWorktree: {}, autoModeByWorktree: {},
autoModeActivityLog: [], autoModeActivityLog: [],
maxConcurrency: DEFAULT_MAX_CONCURRENCY, maxConcurrency: DEFAULT_MAX_CONCURRENCY,
boardViewMode: 'kanban',
// Feature Default Settings
defaultSkipTests: true, defaultSkipTests: true,
enableDependencyBlocking: true, enableDependencyBlocking: true,
skipVerificationInAutoMode: false, skipVerificationInAutoMode: false,
enableAiCommitMessages: true, enableAiCommitMessages: true,
planUseSelectedWorktreeBranch: true, planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false, addFeatureUseSelectedWorktreeBranch: false,
// Worktree Settings
useWorktrees: true, useWorktrees: true,
currentWorktreeByProject: {}, currentWorktreeByProject: {},
worktreesByProject: {}, worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false, // Server Settings
disableSplashScreen: false,
serverLogLevel: 'info', serverLogLevel: 'info',
enableRequestLogging: true, enableRequestLogging: true,
showQueryDevtools: true,
// Model Settings
enhancementModel: 'claude-sonnet', enhancementModel: 'claude-sonnet',
validationModel: 'claude-opus', validationModel: 'claude-opus',
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
favoriteModels: [], favoriteModels: [],
// Cursor CLI Settings
enabledCursorModels: getAllCursorModelIds(), enabledCursorModels: getAllCursorModelIds(),
cursorDefaultModel: 'cursor-auto', cursorDefaultModel: 'cursor-auto',
// Codex CLI Settings
enabledCodexModels: getAllCodexModelIds(), enabledCodexModels: getAllCodexModelIds(),
codexDefaultModel: 'codex-gpt-5.2-codex', codexDefaultModel: 'codex-gpt-5.2-codex',
codexAutoLoadAgents: false, codexAutoLoadAgents: false,
@@ -270,6 +287,8 @@ const initialState: AppState = {
codexApprovalPolicy: 'on-request', codexApprovalPolicy: 'on-request',
codexEnableWebSearch: false, codexEnableWebSearch: false,
codexEnableImages: false, codexEnableImages: false,
// OpenCode CLI Settings
enabledOpencodeModels: getAllOpencodeModelIds(), enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
dynamicOpencodeModels: [], dynamicOpencodeModels: [],
@@ -279,61 +298,101 @@ const initialState: AppState = {
opencodeModelsError: null, opencodeModelsError: null,
opencodeModelsLastFetched: null, opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null, opencodeModelsLastFailedAt: null,
// Gemini CLI Settings
enabledGeminiModels: getAllGeminiModelIds(), enabledGeminiModels: getAllGeminiModelIds(),
geminiDefaultModel: DEFAULT_GEMINI_MODEL, geminiDefaultModel: DEFAULT_GEMINI_MODEL,
// Copilot SDK Settings
enabledCopilotModels: getAllCopilotModelIds(), enabledCopilotModels: getAllCopilotModelIds(),
copilotDefaultModel: DEFAULT_COPILOT_MODEL, copilotDefaultModel: DEFAULT_COPILOT_MODEL,
// Provider Settings
disabledProviders: [], disabledProviders: [],
// Claude Agent SDK Settings
autoLoadClaudeMd: false, autoLoadClaudeMd: false,
skipSandboxWarning: false, skipSandboxWarning: false,
// MCP Servers
mcpServers: [], mcpServers: [],
// Editor Configuration
defaultEditorCommand: null, defaultEditorCommand: null,
// Terminal Configuration
defaultTerminalId: null, defaultTerminalId: null,
// Skills Configuration
enableSkills: true, enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Subagents Configuration
enableSubagents: true, enableSubagents: true,
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Prompt Customization
promptCustomization: {}, promptCustomization: {},
// Event Hooks
eventHooks: [], eventHooks: [],
// Claude-Compatible Providers
claudeCompatibleProviders: [], claudeCompatibleProviders: [],
claudeApiProfiles: [], claudeApiProfiles: [],
activeClaudeApiProfileId: null, activeClaudeApiProfileId: null,
// Project Analysis
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null, // Terminal state
terminalState: defaultTerminalState, terminalState: defaultTerminalState,
terminalLayoutByProject: {}, terminalLayoutByProject: {},
// Spec Creation
specCreatingForProject: null, specCreatingForProject: null,
// Planning
defaultPlanningMode: 'skip' as PlanningMode, defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false, defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
pendingPlanApproval: null, pendingPlanApproval: null,
// Claude Usage Tracking
claudeRefreshInterval: 60, claudeRefreshInterval: 60,
claudeUsage: null, claudeUsage: null,
claudeUsageLastUpdated: null, claudeUsageLastUpdated: null,
// Codex Usage Tracking
codexUsage: null, codexUsage: null,
codexUsageLastUpdated: null, codexUsageLastUpdated: null,
// Codex Models
codexModels: [], codexModels: [],
codexModelsLoading: false, codexModelsLoading: false,
codexModelsError: null, codexModelsError: null,
codexModelsLastFetched: null, codexModelsLastFetched: null,
codexModelsLastFailedAt: null, codexModelsLastFailedAt: null,
// Pipeline Configuration
pipelineConfigByProject: {}, pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {}, // Project-specific Worktree Settings
defaultDeleteBranchByProject: {}, defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {}, useWorktreesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '', // Init Script State
recentFolders: [],
initScriptState: {}, initScriptState: {},
}; };
export const useAppStore = create<AppState & AppActions>()((set, get) => ({ export const useAppStore = create<AppState & AppActions>()((set, get, store) => ({
// Spread initial non-UI state
...initialState, ...initialState,
// Spread UI slice (includes UI state and actions)
...createUISlice(set, get, store),
// Project actions // Project actions
setProjects: (projects) => set({ projects }), setProjects: (projects) => set({ projects }),
@@ -598,28 +657,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
saveProjects(get().projects); saveProjects(get().projects);
}, },
// View actions // View actions - provided by UI slice
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 }),
// Theme actions // Theme actions (setTheme, getEffectiveTheme, setPreviewTheme provided by UI slice)
setTheme: (theme) => {
set({ theme });
saveThemeToStorage(theme);
},
setProjectTheme: (projectId: string, theme: ThemeMode | null) => { setProjectTheme: (projectId: string, theme: ThemeMode | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
@@ -644,34 +684,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Persist to storage // Persist to storage
saveProjects(get().projects); 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 // Font actions (setFontSans, setFontMono, getEffectiveFontSans, getEffectiveFontMono provided by UI slice)
setFontSans: (fontFamily) => {
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},
setProjectFontSans: (projectId: string, fontFamily: string | null) => { setProjectFontSans: (projectId: string, fontFamily: string | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => 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 // Also update currentProject if it's the one being changed
currentProject: currentProject:
state.currentProject?.id === projectId state.currentProject?.id === projectId
? { ...state.currentProject, fontSans: fontFamily ?? undefined } ? { ...state.currentProject, fontFamilySans: fontFamily ?? undefined }
: state.currentProject, : state.currentProject,
})); }));
@@ -681,28 +704,18 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setProjectFontMono: (projectId: string, fontFamily: string | null) => { setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => 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 // Also update currentProject if it's the one being changed
currentProject: currentProject:
state.currentProject?.id === projectId state.currentProject?.id === projectId
? { ...state.currentProject, fontMono: fontFamily ?? undefined } ? { ...state.currentProject, fontFamilyMono: fontFamily ?? undefined }
: state.currentProject, : state.currentProject,
})); }));
// Persist to storage // Persist to storage
saveProjects(get().projects); saveProjects(get().projects);
}, },
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilyMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
// Claude API Profile actions (per-project override) // Claude API Profile actions (per-project override)
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => { setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => {
@@ -886,8 +899,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentChatSession: currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession, state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
})), })),
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }), // setChatHistoryOpen and toggleChatHistory - provided by UI slice
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
// Auto Mode actions (per-worktree) // Auto Mode actions (per-worktree)
getWorktreeKey: (projectId: string, branchName: string | null) => getWorktreeKey: (projectId: string, branchName: string | null) =>
@@ -1018,8 +1030,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
}, },
// Kanban Card Settings actions // Kanban Card Settings actions - setBoardViewMode provided by UI slice
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
// Feature Default Settings actions // Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -1094,29 +1105,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return mainWorktree?.branch ?? null; return mainWorktree?.branch ?? null;
}, },
// Keyboard Shortcuts actions // Keyboard Shortcuts actions - provided by UI slice
setKeyboardShortcut: (key, value) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
})),
setKeyboardShortcuts: (shortcuts) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
})),
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
// Audio Settings actions // Audio Settings actions - setMuteDoneSound provided by UI slice
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Splash Screen actions // Splash Screen actions - setDisableSplashScreen provided by UI slice
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Server Log Level actions // Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }), setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }), setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
// Developer Tools actions // Developer Tools actions - setShowQueryDevtools provided by UI slice
setShowQueryDevtools: (show) => set({ showQueryDevtools: show }),
// Enhancement Model actions // Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }), setEnhancementModel: (model) => set({ enhancementModel: model }),
@@ -1486,96 +1485,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null, getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
// Board Background actions // Board Background actions - provided by UI slice
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 };
}),
// Terminal actions // Terminal actions
setTerminalUnlocked: (unlocked, token) => setTerminalUnlocked: (unlocked, token) =>
@@ -2325,27 +2235,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
}), }),
// Worktree Panel Visibility actions // Worktree Panel Visibility actions - provided by UI slice
setWorktreePanelVisible: (projectPath, visible) =>
set((state) => ({
worktreePanelVisibleByProject: {
...state.worktreePanelVisibleByProject,
[projectPath]: visible,
},
})),
getWorktreePanelVisible: (projectPath) =>
get().worktreePanelVisibleByProject[projectPath] ?? true,
// Init Script Indicator Visibility actions // Init Script Indicator Visibility actions - provided by UI slice
setShowInitScriptIndicator: (projectPath, visible) =>
set((state) => ({
showInitScriptIndicatorByProject: {
...state.showInitScriptIndicatorByProject,
[projectPath]: visible,
},
})),
getShowInitScriptIndicator: (projectPath) =>
get().showInitScriptIndicatorByProject[projectPath] ?? true,
// Default Delete Branch actions // Default Delete Branch actions
setDefaultDeleteBranch: (projectPath, deleteBranch) => setDefaultDeleteBranch: (projectPath, deleteBranch) =>
@@ -2357,16 +2249,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false, getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false,
// Auto-dismiss Init Script Indicator actions // Auto-dismiss Init Script Indicator actions - provided by UI slice
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) =>
set((state) => ({
autoDismissInitScriptIndicatorByProject: {
...state.autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
})),
getAutoDismissInitScriptIndicator: (projectPath) =>
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
// Use Worktrees Override actions // Use Worktrees Override actions
setProjectUseWorktrees: (projectPath, useWorktrees) => setProjectUseWorktrees: (projectPath, useWorktrees) =>
@@ -2382,15 +2265,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return projectOverride !== undefined ? projectOverride : get().useWorktrees; return projectOverride !== undefined ? projectOverride : get().useWorktrees;
}, },
// UI State actions // UI State actions - provided by UI slice
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) };
}),
// Claude Usage Tracking actions // Claude Usage Tracking actions
setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }), setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }),

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; closeTerminal: string;
newTerminalTab: 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

@@ -21,13 +21,9 @@ services:
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode # - ~/.local/share/opencode:/home/automaker/.local/share/opencode
# - ~/.config/opencode:/home/automaker/.config/opencode # - ~/.config/opencode:/home/automaker/.config/opencode
# ===== Playwright Browser Cache (Optional) ===== # Playwright browser cache - persists installed browsers across container restarts
# Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing. # Run 'npx playwright install --with-deps chromium' once, and it will persist
# Uncomment below to persist browser cache across container rebuilds (saves ~300MB download):
# - playwright-cache:/home/automaker/.cache/ms-playwright # - playwright-cache:/home/automaker/.cache/ms-playwright
#
# To update Playwright browsers manually:
# docker exec --user automaker -w /app automaker-server npx playwright install chromium
environment: environment:
# Set root directory for all projects and file operations # Set root directory for all projects and file operations
# Users can only create/open projects within this directory # Users can only create/open projects within this directory
@@ -41,7 +37,6 @@ services:
# - CURSOR_API_KEY=${CURSOR_API_KEY:-} # - CURSOR_API_KEY=${CURSOR_API_KEY:-}
volumes: volumes:
# Playwright cache volume - optional, persists browser updates across container rebuilds # Playwright cache volume (persists Chromium installs)
# Uncomment if you mounted the playwright-cache volume above
# playwright-cache: # playwright-cache:
# name: automaker-playwright-cache # name: automaker-playwright-cache

View File

@@ -750,9 +750,6 @@ export function electronUserDataWriteFileSync(
throw new Error('[SystemPaths] Electron userData path not initialized'); throw new Error('[SystemPaths] Electron userData path not initialized');
} }
const fullPath = path.join(electronUserDataPath, relativePath); const fullPath = path.join(electronUserDataPath, relativePath);
// Ensure parent directory exists (may not exist on first launch)
const dir = path.dirname(fullPath);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(fullPath, data, options); fsSync.writeFileSync(fullPath, data, options);
} }