+ {['select-folder', 'project-name', 'app-spec'].map((s, i) => (
+
i
+ ? 'bg-primary/50'
+ : 'bg-muted'
+ )}
+ />
+ ))}
+
+
+ {renderStep()}
+
+
+ );
+}
diff --git a/apps/ui/src/components/dialogs/settings-dialog/index.ts b/apps/ui/src/components/dialogs/settings-dialog/index.ts
new file mode 100644
index 00000000..ff952abe
--- /dev/null
+++ b/apps/ui/src/components/dialogs/settings-dialog/index.ts
@@ -0,0 +1 @@
+export { SettingsDialog } from './settings-dialog';
diff --git a/apps/ui/src/components/dialogs/settings-dialog/settings-dialog.tsx b/apps/ui/src/components/dialogs/settings-dialog/settings-dialog.tsx
new file mode 100644
index 00000000..d62b7086
--- /dev/null
+++ b/apps/ui/src/components/dialogs/settings-dialog/settings-dialog.tsx
@@ -0,0 +1,59 @@
+import { Settings } from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { SettingsContent } from '@/components/views/settings-view/settings-content';
+import { cn } from '@/lib/utils';
+
+interface SettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
new file mode 100644
index 00000000..ab592757
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx
@@ -0,0 +1,564 @@
+import { useState, useCallback, useSyncExternalStore } from 'react';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import {
+ Terminal,
+ Bot,
+ FileText,
+ FolderOpen,
+ Github,
+ ChevronUp,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ Maximize2,
+ Minimize2,
+ MessageSquare,
+ Sparkles,
+ PanelBottom,
+ PanelRight,
+ PanelLeft,
+} from 'lucide-react';
+import {
+ GitHubPanel,
+ AgentsPanel,
+ SpecPanel,
+ ContextPanel,
+ TerminalPanelDock,
+ ChatPanel,
+ IdeationPanel,
+} from './panels';
+
+type DockTab = 'terminal' | 'agents' | 'spec' | 'context' | 'github' | 'chat' | 'ideation';
+export type DockPosition = 'bottom' | 'right' | 'left';
+
+const DOCK_POSITION_STORAGE_KEY = 'automaker:dock-position';
+
+// Event emitter for dock position changes
+const positionListeners = new Set<() => void>();
+
+function emitPositionChange() {
+ positionListeners.forEach((listener) => listener());
+}
+
+// Cached position to avoid creating new objects on every read
+let cachedPosition: DockPosition = 'bottom';
+
+// Initialize from localStorage
+try {
+ const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null;
+ if (stored && ['bottom', 'right', 'left'].includes(stored)) {
+ cachedPosition = stored;
+ }
+} catch {
+ // Ignore localStorage errors
+}
+
+function getPosition(): DockPosition {
+ return cachedPosition;
+}
+
+function updatePosition(position: DockPosition) {
+ if (cachedPosition !== position) {
+ cachedPosition = position;
+ try {
+ localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position);
+ } catch {
+ // Ignore localStorage errors
+ }
+ emitPositionChange();
+ }
+}
+
+// Hook for external components to read dock position
+export function useDockState(): { position: DockPosition } {
+ const position = useSyncExternalStore(
+ (callback) => {
+ positionListeners.add(callback);
+ return () => positionListeners.delete(callback);
+ },
+ getPosition,
+ getPosition
+ );
+ return { position };
+}
+
+interface BottomDockProps {
+ className?: string;
+}
+
+export function BottomDock({ className }: BottomDockProps) {
+ const { currentProject, getAutoModeState } = useAppStore();
+
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [activeTab, setActiveTab] = useState
(null);
+ const [isMaximized, setIsMaximized] = useState(false);
+
+ // Use external store for position - single source of truth
+ const position = useSyncExternalStore(
+ (callback) => {
+ positionListeners.add(callback);
+ return () => positionListeners.delete(callback);
+ },
+ getPosition,
+ getPosition
+ );
+
+ const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
+ const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
+
+ const handleTabClick = useCallback(
+ (tab: DockTab) => {
+ if (activeTab === tab) {
+ setIsExpanded(!isExpanded);
+ } else {
+ setActiveTab(tab);
+ setIsExpanded(true);
+ }
+ },
+ [activeTab, isExpanded]
+ );
+
+ const handleDoubleClick = useCallback(() => {
+ if (isExpanded) {
+ setIsMaximized(!isMaximized);
+ } else {
+ setIsExpanded(true);
+ if (!activeTab) {
+ setActiveTab('terminal');
+ }
+ }
+ }, [isExpanded, isMaximized, activeTab]);
+
+ // All tabs combined for easier rendering
+ const allTabs = [
+ {
+ id: 'terminal' as DockTab,
+ label: 'Terminal',
+ icon: Terminal,
+ badge: null,
+ badgeColor: undefined,
+ group: 'operations',
+ },
+ {
+ id: 'chat' as DockTab,
+ label: 'Chat',
+ icon: MessageSquare,
+ badge: null,
+ badgeColor: undefined,
+ group: 'operations',
+ },
+ {
+ id: 'ideation' as DockTab,
+ label: 'Ideate',
+ icon: Sparkles,
+ badge: null,
+ badgeColor: undefined,
+ group: 'planning',
+ },
+ {
+ id: 'spec' as DockTab,
+ label: 'Spec',
+ icon: FileText,
+ badge: null,
+ badgeColor: undefined,
+ group: 'planning',
+ },
+ {
+ id: 'context' as DockTab,
+ label: 'Context',
+ icon: FolderOpen,
+ badge: null,
+ badgeColor: undefined,
+ group: 'planning',
+ },
+ {
+ id: 'github' as DockTab,
+ label: 'GitHub',
+ icon: Github,
+ badge: null,
+ badgeColor: undefined,
+ group: 'planning',
+ },
+ {
+ id: 'agents' as DockTab,
+ label: 'Agents',
+ icon: Bot,
+ badge: runningAgentsCount > 0 ? runningAgentsCount : null,
+ badgeColor: 'bg-green-500',
+ group: 'agents',
+ },
+ ];
+
+ const isRightDock = position === 'right';
+ const isLeftDock = position === 'left';
+ const isSideDock = isRightDock || isLeftDock;
+
+ // Render panel content directly to avoid remounting on state changes
+ const renderPanelContent = () => (
+ <>
+ {activeTab === 'terminal' && }
+ {activeTab === 'agents' && }
+ {activeTab === 'spec' && }
+ {activeTab === 'context' && }
+ {activeTab === 'github' && }
+ {activeTab === 'chat' && }
+ {activeTab === 'ideation' && }
+ >
+ );
+
+ // Side dock layout (left or right)
+ if (isSideDock) {
+ const dockWidth = isMaximized ? 'w-[50vw]' : isExpanded ? 'w-96' : 'w-10';
+
+ return (
+
+ {/* Vertical Tab Bar */}
+
+ {/* Tab Icons */}
+
+ {allTabs.map((tab, index) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id && isExpanded;
+ const showDivider = (index === 1 || index === 5) && index < allTabs.length - 1;
+
+ return (
+
+
+ {showDivider &&
}
+
+ );
+ })}
+
+
+
+
+ {/* Dock Controls */}
+
e.stopPropagation()}>
+ {/* Position buttons - show other positions (not current) */}
+ {position !== 'left' && (
+
+ )}
+ {position !== 'bottom' && (
+
+ )}
+ {position !== 'right' && (
+
+ )}
+
+ {isExpanded && (
+
+ )}
+
+
+
+
+
+ {/* Panel Content */}
+ {isExpanded &&
{renderPanelContent()}
}
+
+ );
+ }
+
+ // Bottom dock layout - uses fixed positioning like side docks
+ const dockHeight = isMaximized ? 'h-[70vh]' : isExpanded ? 'h-72' : 'h-10';
+
+ // Group tabs for bottom layout
+ const operationsTabs = allTabs.filter((t) => t.group === 'operations');
+ const planningTabs = allTabs.filter((t) => t.group === 'planning');
+ const agentTab = allTabs.find((t) => t.group === 'agents')!;
+
+ return (
+
+ {/* Tab Bar - double click to expand/maximize */}
+
+
+ {/* Operations tabs */}
+ {operationsTabs.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id && isExpanded;
+
+ return (
+
+ );
+ })}
+
+ {/* Divider */}
+
+
+ {/* Planning tabs */}
+ {planningTabs.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = activeTab === tab.id && isExpanded;
+
+ return (
+
+ );
+ })}
+
+ {/* Divider */}
+
+
+ {/* Agents tab (separate section) */}
+ {(() => {
+ const Icon = agentTab.icon;
+ const isActive = activeTab === agentTab.id && isExpanded;
+
+ return (
+
+ );
+ })()}
+
+
+
+
+ {/* Dock Controls */}
+
e.stopPropagation()}>
+ {/* Position buttons - show other positions (not current) */}
+
+
+
+ {isExpanded && (
+
+ )}
+
+
+
+
+ {/* Panel Content */}
+ {isExpanded &&
{renderPanelContent()}
}
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/index.ts b/apps/ui/src/components/layout/bottom-dock/index.ts
new file mode 100644
index 00000000..0b2bef20
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/index.ts
@@ -0,0 +1,2 @@
+export { BottomDock, useDockState } from './bottom-dock';
+export type { DockPosition } from './bottom-dock';
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/agents-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/agents-panel.tsx
new file mode 100644
index 00000000..e76f557f
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/agents-panel.tsx
@@ -0,0 +1,143 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Bot, Square, Loader2, Activity } from 'lucide-react';
+import { getElectronAPI, RunningAgent } from '@/lib/electron';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { toast } from 'sonner';
+
+export function AgentsPanel() {
+ const { currentProject } = useAppStore();
+ const [runningAgents, setRunningAgents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [stoppingAgents, setStoppingAgents] = useState>(new Set());
+
+ const fetchRunningAgents = useCallback(async () => {
+ try {
+ const api = getElectronAPI();
+ if (api.runningAgents) {
+ const result = await api.runningAgents.getAll();
+ if (result.success && result.runningAgents) {
+ // Filter to current project if one is selected
+ const agents = currentProject?.path
+ ? result.runningAgents.filter((a) => a.projectPath === currentProject.path)
+ : result.runningAgents;
+ setRunningAgents(agents);
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching running agents:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [currentProject?.path]);
+
+ // Initial fetch and auto-refresh
+ useEffect(() => {
+ fetchRunningAgents();
+ const interval = setInterval(fetchRunningAgents, 2000);
+ return () => clearInterval(interval);
+ }, [fetchRunningAgents]);
+
+ // Subscribe to auto-mode events
+ useEffect(() => {
+ const api = getElectronAPI();
+ if (!api.autoMode) return;
+
+ const unsubscribe = api.autoMode.onEvent((event) => {
+ if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
+ fetchRunningAgents();
+ }
+ });
+
+ return () => unsubscribe();
+ }, [fetchRunningAgents]);
+
+ const handleStopAgent = useCallback(async (featureId: string) => {
+ setStoppingAgents((prev) => new Set(prev).add(featureId));
+ try {
+ const api = getElectronAPI();
+ if (api.autoMode) {
+ await api.autoMode.stopFeature(featureId);
+ toast.success('Agent stopped');
+ }
+ } catch (error) {
+ toast.error('Failed to stop agent');
+ } finally {
+ setStoppingAgents((prev) => {
+ const next = new Set(prev);
+ next.delete(featureId);
+ return next;
+ });
+ }
+ }, []);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
{runningAgents.length} Running
+
+
+
+ {/* Content */}
+
+
+ {runningAgents.length === 0 ? (
+
+
+
No agents running
+
+ Enable Auto Mode to start processing features
+
+
+ ) : (
+ runningAgents.map((agent) => (
+
+
+
+
{agent.featureTitle}
+
+ {agent.status === 'running' ? 'In progress...' : agent.status}
+
+
+
+
+ {agent.currentPhase && (
+
+
+
+ {agent.currentPhase}
+
+
+ )}
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
new file mode 100644
index 00000000..ed904556
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/chat-panel.tsx
@@ -0,0 +1,697 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import {
+ MessageSquare,
+ Plus,
+ Loader2,
+ Archive,
+ ArchiveRestore,
+ Trash2,
+ X,
+ Send,
+ Square,
+ Bot,
+ User,
+ AlertCircle,
+ ArchiveX,
+} from 'lucide-react';
+import { getElectronAPI } from '@/lib/electron';
+import { useAppStore } from '@/store/app-store';
+import { useElectronAgent } from '@/hooks/use-electron-agent';
+import { Button } from '@/components/ui/button';
+import { Markdown } from '@/components/ui/markdown';
+import { cn } from '@/lib/utils';
+import { AgentModelSelector } from '@/components/views/agent-view/shared/agent-model-selector';
+import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
+import type { SessionListItem } from '@/types/electron';
+import type { Message } from '@/types/electron';
+import type { PhaseModelEntry } from '@automaker/types';
+
+// Random session name generator
+const adjectives = [
+ 'Swift',
+ 'Bright',
+ 'Clever',
+ 'Dynamic',
+ 'Eager',
+ 'Focused',
+ 'Gentle',
+ 'Happy',
+ 'Inventive',
+ 'Jolly',
+ 'Keen',
+ 'Lively',
+ 'Mighty',
+ 'Noble',
+ 'Optimal',
+ 'Peaceful',
+];
+
+const nouns = [
+ 'Agent',
+ 'Builder',
+ 'Coder',
+ 'Developer',
+ 'Explorer',
+ 'Forge',
+ 'Garden',
+ 'Helper',
+ 'Journey',
+ 'Mission',
+ 'Navigator',
+ 'Project',
+ 'Quest',
+ 'Runner',
+ 'Spark',
+ 'Task',
+];
+
+function generateRandomSessionName(): string {
+ const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
+ const number = Math.floor(Math.random() * 100);
+ return `${adjective} ${noun} ${number}`;
+}
+
+// Compact message bubble for dock panel
+function CompactMessageBubble({ message }: { message: Message }) {
+ const isError = message.isError && message.role === 'assistant';
+
+ return (
+
+ {/* Avatar */}
+
+ {isError ? (
+
+ ) : message.role === 'assistant' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Message */}
+
+ {message.role === 'assistant' ? (
+
+ {message.content}
+
+ ) : (
+
{message.content}
+ )}
+
+
+ );
+}
+
+// Compact thinking indicator
+function CompactThinkingIndicator() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
Thinking...
+
+
+
+ );
+}
+
+// Embedded chat component for a session
+function EmbeddedChat({ sessionId, projectPath }: { sessionId: string; projectPath: string }) {
+ const [input, setInput] = useState('');
+ const [modelSelection, setModelSelection] = useState({ model: 'sonnet' });
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const { messages, isProcessing, isConnected, sendMessage, stopExecution } = useElectronAgent({
+ sessionId,
+ workingDirectory: projectPath,
+ model: modelSelection.model,
+ thinkingLevel: modelSelection.thinkingLevel,
+ });
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages, isProcessing]);
+
+ // Focus input on mount
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, [sessionId]);
+
+ const handleSend = useCallback(async () => {
+ if (!input.trim() || isProcessing) return;
+ const messageContent = input;
+ setInput('');
+ await sendMessage(messageContent);
+ }, [input, isProcessing, sendMessage]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ // Show welcome message if no messages
+ const displayMessages =
+ messages.length === 0
+ ? [
+ {
+ id: 'welcome',
+ role: 'assistant' as const,
+ content: "Hello! I'm the Automaker Agent. How can I help you today?",
+ timestamp: new Date().toISOString(),
+ },
+ ]
+ : messages;
+
+ return (
+
+ {/* Messages area */}
+
+ {displayMessages.map((message) => (
+
+ ))}
+ {isProcessing &&
}
+
+
+
+ {/* Input area */}
+
+
+
setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={isConnected ? 'Type a message...' : 'Connecting...'}
+ disabled={!isConnected}
+ className={cn(
+ 'flex-1 h-8 rounded-md border border-border bg-background px-3 text-xs',
+ 'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary'
+ )}
+ />
+
+ {isProcessing ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+export function ChatPanel() {
+ const { currentProject } = useAppStore();
+ const [sessions, setSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [activeSessionId, setActiveSessionId] = useState(null);
+ const [showArchived, setShowArchived] = useState(false);
+ const [archivingAll, setArchivingAll] = useState(false);
+
+ // Delete dialog state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [sessionToDelete, setSessionToDelete] = useState(null);
+
+ const loadSessions = useCallback(async () => {
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ const result = await api.sessions.list(true);
+ if (result.success && result.sessions) {
+ setSessions(result.sessions);
+ // Set active session to first active session if none selected
+ const activeSessions = result.sessions.filter((s) => !s.isArchived);
+ if (!activeSessionId && activeSessions.length > 0) {
+ setActiveSessionId(activeSessions[0].id);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Error fetching sessions:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [activeSessionId]);
+
+ useEffect(() => {
+ loadSessions();
+ }, [loadSessions]);
+
+ const handleCreateSession = useCallback(async () => {
+ if (!currentProject) return;
+
+ setCreating(true);
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ const sessionName = generateRandomSessionName();
+ const result = await api.sessions.create(
+ sessionName,
+ currentProject.path,
+ currentProject.path
+ );
+ if (result.success && result.session?.id) {
+ await loadSessions();
+ setActiveSessionId(result.session.id);
+ setShowArchived(false);
+ }
+ }
+ } catch (error) {
+ console.error('Error creating session:', error);
+ } finally {
+ setCreating(false);
+ }
+ }, [currentProject, loadSessions]);
+
+ const handleArchiveSession = useCallback(
+ async (sessionId: string, e?: React.MouseEvent) => {
+ e?.stopPropagation();
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ await api.sessions.archive(sessionId);
+ await loadSessions();
+ // If archived session was active, switch to first active session
+ if (sessionId === activeSessionId) {
+ const updatedSessions = sessions.filter((s) => s.id !== sessionId && !s.isArchived);
+ setActiveSessionId(updatedSessions.length > 0 ? updatedSessions[0].id : null);
+ }
+ }
+ } catch (error) {
+ console.error('Error archiving session:', error);
+ }
+ },
+ [loadSessions, activeSessionId, sessions]
+ );
+
+ const handleArchiveAll = useCallback(async () => {
+ const activeSessions = sessions.filter((s) => !s.isArchived);
+ if (activeSessions.length === 0) return;
+
+ setArchivingAll(true);
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ for (const session of activeSessions) {
+ await api.sessions.archive(session.id);
+ }
+ await loadSessions();
+ setActiveSessionId(null);
+ }
+ } catch (error) {
+ console.error('Error archiving all sessions:', error);
+ } finally {
+ setArchivingAll(false);
+ }
+ }, [sessions, loadSessions]);
+
+ const handleUnarchiveSession = useCallback(
+ async (sessionId: string, e?: React.MouseEvent) => {
+ e?.stopPropagation();
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ await api.sessions.unarchive(sessionId);
+ await loadSessions();
+ setActiveSessionId(sessionId);
+ setShowArchived(false);
+ }
+ } catch (error) {
+ console.error('Error unarchiving session:', error);
+ }
+ },
+ [loadSessions]
+ );
+
+ const handleDeleteSession = useCallback((session: SessionListItem, e?: React.MouseEvent) => {
+ e?.stopPropagation();
+ setSessionToDelete(session);
+ setDeleteDialogOpen(true);
+ }, []);
+
+ const confirmDeleteSession = useCallback(
+ async (sessionId: string) => {
+ try {
+ const api = getElectronAPI();
+ if (api?.sessions) {
+ await api.sessions.delete(sessionId);
+ await loadSessions();
+ // If deleted session was active, switch to first available session
+ if (sessionId === activeSessionId) {
+ const remainingSessions = sessions.filter((s) => s.id !== sessionId);
+ const activeSessions = remainingSessions.filter((s) => !s.isArchived);
+ setActiveSessionId(activeSessions.length > 0 ? activeSessions[0].id : null);
+ }
+ }
+ } catch (error) {
+ console.error('Error deleting session:', error);
+ } finally {
+ setDeleteDialogOpen(false);
+ setSessionToDelete(null);
+ }
+ },
+ [loadSessions, activeSessionId, sessions]
+ );
+
+ const activeSessions = sessions.filter((s) => !s.isArchived);
+ const archivedSessions = sessions.filter((s) => s.isArchived);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!currentProject) {
+ return (
+
+
+
+
Select a project to start chatting
+
+
+ );
+ }
+
+ // Show archived sessions list view
+ if (showArchived) {
+ return (
+
+ {/* Header */}
+
+
+
+
{archivedSessions.length} Archived
+
+
+
+
+ {/* Archived Sessions List */}
+
+
+ {archivedSessions.length === 0 ? (
+
+
+
No archived sessions
+
+ ) : (
+ archivedSessions.map((session) => (
+
+
+
+
+
+
+ {session.messageCount} messages
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+ {/* Delete Dialog */}
+
+
+ );
+ }
+
+ // No active sessions - show empty state
+ if (activeSessions.length === 0) {
+ return (
+
+ {/* Header */}
+
+
+
+ Chat
+
+
+ {archivedSessions.length > 0 && (
+
+ )}
+
+
+
+
+ {/* Empty State */}
+
+
+
+
No chat sessions
+
+
+
+
+ );
+ }
+
+ // Active sessions view with tabs and embedded chat
+ return (
+
+ {/* Tab bar */}
+
+ {activeSessions.map((session) => (
+
+ ))}
+
+
+ {creating ? : }
+
+
+
+
+
+ {activeSessions.length > 1 && (
+
+ {archivingAll ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {archivedSessions.length > 0 && (
+
setShowArchived(true)}
+ title="View archived sessions"
+ >
+
+ {archivedSessions.length}
+
+ )}
+
+
+
+ {/* Embedded chat content */}
+
+ {activeSessionId && currentProject ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Delete Dialog */}
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/context-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/context-panel.tsx
new file mode 100644
index 00000000..38f81176
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/context-panel.tsx
@@ -0,0 +1,441 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { FolderOpen, FileText, Image, Loader2, Upload, FilePlus } from 'lucide-react';
+import { getElectronAPI } from '@/lib/electron';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { useAppStore } from '@/store/app-store';
+import { cn } from '@/lib/utils';
+import { sanitizeFilename } from '@/lib/image-utils';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+
+interface ContextFile {
+ name: string;
+ type: 'text' | 'image';
+ path: string;
+ description?: string;
+}
+
+interface ContextMetadata {
+ files: Record;
+}
+
+export function ContextPanel() {
+ const { currentProject } = useAppStore();
+ const [files, setFiles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [fileContent, setFileContent] = useState('');
+ const [isDropHovering, setIsDropHovering] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [generatingDescriptions, setGeneratingDescriptions] = useState>(new Set());
+ const fileInputRef = useRef(null);
+
+ // Helper functions
+ const isImageFile = (filename: string): boolean => {
+ const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
+ return imageExtensions.includes(ext);
+ };
+
+ const getContextPath = useCallback(() => {
+ if (!currentProject) return null;
+ return `${currentProject.path}/.automaker/context`;
+ }, [currentProject]);
+
+ // Load context metadata
+ const loadMetadata = useCallback(async (): Promise => {
+ const contextPath = getContextPath();
+ if (!contextPath) return { files: {} };
+
+ try {
+ const api = getElectronAPI();
+ const metadataPath = `${contextPath}/context-metadata.json`;
+ const result = await api.readFile(metadataPath);
+ if (result.success && result.content) {
+ return JSON.parse(result.content);
+ }
+ } catch {
+ // Metadata file doesn't exist yet
+ }
+ return { files: {} };
+ }, [getContextPath]);
+
+ // Save context metadata
+ const saveMetadata = useCallback(
+ async (metadata: ContextMetadata) => {
+ const contextPath = getContextPath();
+ if (!contextPath) return;
+
+ try {
+ const api = getElectronAPI();
+ const metadataPath = `${contextPath}/context-metadata.json`;
+ await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
+ } catch (error) {
+ console.error('Failed to save metadata:', error);
+ }
+ },
+ [getContextPath]
+ );
+
+ const loadContextFiles = useCallback(async () => {
+ const contextPath = getContextPath();
+ if (!contextPath) return;
+
+ setLoading(true);
+ try {
+ const api = getElectronAPI();
+
+ // Ensure context directory exists
+ await api.mkdir(contextPath);
+
+ // Load metadata for descriptions
+ const metadata = await loadMetadata();
+
+ // Read directory contents
+ const result = await api.readdir(contextPath);
+ if (result.success && result.entries) {
+ const contextFiles: ContextFile[] = result.entries
+ .filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
+ .map((entry) => ({
+ name: entry.name,
+ type: isImageFile(entry.name) ? 'image' : 'text',
+ path: `${contextPath}/${entry.name}`,
+ description: metadata.files[entry.name]?.description,
+ }));
+ setFiles(contextFiles);
+ }
+ } catch (error) {
+ console.error('Error loading context files:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [getContextPath, loadMetadata]);
+
+ useEffect(() => {
+ loadContextFiles();
+ }, [loadContextFiles]);
+
+ const handleSelectFile = useCallback(async (file: ContextFile) => {
+ if (file.type === 'image') {
+ setSelectedFile(file);
+ setFileContent('');
+ return;
+ }
+
+ try {
+ const api = getElectronAPI();
+ const result = await api.readFile(file.path);
+ if (result.success && result.content) {
+ setSelectedFile(file);
+ setFileContent(result.content);
+ }
+ } catch (error) {
+ console.error('Error reading file:', error);
+ }
+ }, []);
+
+ // Generate description for a file
+ const generateDescription = async (
+ filePath: string,
+ fileName: string,
+ isImage: boolean
+ ): Promise => {
+ try {
+ const httpClient = getHttpApiClient();
+ const result = isImage
+ ? await httpClient.context.describeImage(filePath)
+ : await httpClient.context.describeFile(filePath);
+
+ if (result.success && result.description) {
+ return result.description;
+ }
+ } catch (error) {
+ console.error('Failed to generate description:', error);
+ }
+ return undefined;
+ };
+
+ // Generate description in background and update metadata
+ const generateDescriptionAsync = useCallback(
+ async (filePath: string, fileName: string, isImage: boolean) => {
+ setGeneratingDescriptions((prev) => new Set(prev).add(fileName));
+
+ try {
+ const description = await generateDescription(filePath, fileName, isImage);
+
+ if (description) {
+ const metadata = await loadMetadata();
+ metadata.files[fileName] = { description };
+ await saveMetadata(metadata);
+ await loadContextFiles();
+
+ setSelectedFile((current) => {
+ if (current?.name === fileName) {
+ return { ...current, description };
+ }
+ return current;
+ });
+ }
+ } catch (error) {
+ console.error('Failed to generate description:', error);
+ } finally {
+ setGeneratingDescriptions((prev) => {
+ const next = new Set(prev);
+ next.delete(fileName);
+ return next;
+ });
+ }
+ },
+ [loadMetadata, saveMetadata, loadContextFiles]
+ );
+
+ // Upload a file
+ const uploadFile = async (file: globalThis.File) => {
+ const contextPath = getContextPath();
+ if (!contextPath) return;
+
+ setIsUploading(true);
+
+ try {
+ const api = getElectronAPI();
+ const isImage = isImageFile(file.name);
+
+ let filePath: string;
+ let fileName: string;
+ let imagePathForDescription: string | undefined;
+
+ if (isImage) {
+ fileName = sanitizeFilename(file.name);
+
+ const dataUrl = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = (event) => resolve(event.target?.result as string);
+ reader.readAsDataURL(file);
+ });
+
+ const base64Data = dataUrl.split(',')[1] || dataUrl;
+ const mimeType = file.type || 'image/png';
+
+ const saveResult = await api.saveImageToTemp?.(
+ base64Data,
+ fileName,
+ mimeType,
+ currentProject!.path
+ );
+
+ if (!saveResult?.success || !saveResult.path) {
+ throw new Error(saveResult?.error || 'Failed to save image');
+ }
+
+ imagePathForDescription = saveResult.path;
+ filePath = `${contextPath}/${fileName}`;
+ await api.writeFile(filePath, dataUrl);
+ } else {
+ fileName = file.name;
+ filePath = `${contextPath}/${fileName}`;
+
+ const content = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = (event) => resolve(event.target?.result as string);
+ reader.readAsText(file);
+ });
+
+ await api.writeFile(filePath, content);
+ }
+
+ await loadContextFiles();
+ generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
+ } catch (error) {
+ console.error('Failed to upload file:', error);
+ toast.error('Failed to upload file', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // Handle file drop
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropHovering(false);
+
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ if (droppedFiles.length === 0) return;
+
+ for (const file of droppedFiles) {
+ await uploadFile(file);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropHovering(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropHovering(false);
+ };
+
+ // Handle file import via button
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileInputChange = async (e: React.ChangeEvent) => {
+ const inputFiles = e.target.files;
+ if (!inputFiles || inputFiles.length === 0) return;
+
+ for (const file of Array.from(inputFiles)) {
+ await uploadFile(file);
+ }
+
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Hidden file input */}
+
+
+ {/* Drop overlay */}
+ {isDropHovering && (
+
+
+
+ Drop files to upload
+
+
+ )}
+
+ {/* Uploading overlay */}
+ {isUploading && (
+
+ )}
+
+ {/* File List */}
+
+
+ Files
+
+
+
+
+
+
+ {files.length === 0 ? (
+
+
+
+ No context files.
+
+ Drop files here or click +
+
+
+ ) : (
+ files.map((file) => {
+ const isGenerating = generatingDescriptions.has(file.name);
+ return (
+
handleSelectFile(file)}
+ className={cn(
+ 'w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-left',
+ 'text-xs transition-colors',
+ selectedFile?.name === file.name
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
+ )}
+ >
+ {file.type === 'image' ? (
+
+ ) : (
+
+ )}
+
+ {file.name}
+ {isGenerating && (
+
+
+ Generating...
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* Content Preview */}
+
+
+ {selectedFile?.name || 'Select a file'}
+
+
+
+ {selectedFile ? (
+ selectedFile.type === 'image' ? (
+
+
+
+ Image preview not available in panel
+
+
+ ) : (
+
+ {fileContent || 'No content'}
+
+ )
+ ) : (
+
+
+
Select a file to preview
+
Or drop files to add them
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx
new file mode 100644
index 00000000..50999878
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx
@@ -0,0 +1,224 @@
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { CircleDot, GitPullRequest, RefreshCw, ExternalLink, Loader2 } from 'lucide-react';
+import { getElectronAPI, GitHubIssue, GitHubPR } from '@/lib/electron';
+import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+type GitHubTab = 'issues' | 'prs';
+
+// Cache duration: 5 minutes
+const CACHE_DURATION_MS = 5 * 60 * 1000;
+
+export function GitHubPanel() {
+ const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
+ const [activeTab, setActiveTab] = useState('issues');
+ const fetchingRef = useRef(false);
+
+ const projectPath = currentProject?.path || '';
+ const cache = getGitHubCache(projectPath);
+
+ const issues = cache?.issues || [];
+ const prs = cache?.prs || [];
+ const isFetching = cache?.isFetching || false;
+ const lastFetched = cache?.lastFetched || null;
+ const hasCache = issues.length > 0 || prs.length > 0 || lastFetched !== null;
+
+ const fetchData = useCallback(
+ async (isBackgroundRefresh = false) => {
+ if (!projectPath || fetchingRef.current) return;
+
+ fetchingRef.current = true;
+ if (!isBackgroundRefresh) {
+ setGitHubCacheFetching(projectPath, true);
+ }
+
+ try {
+ const api = getElectronAPI();
+ const fetchedIssues: GitHubCacheIssue[] = [];
+ const fetchedPrs: GitHubCachePR[] = [];
+
+ // Fetch issues
+ if (api.github?.listIssues) {
+ const issuesResult = await api.github.listIssues(projectPath);
+ if (issuesResult.success && issuesResult.openIssues) {
+ // Map to cache format
+ fetchedIssues.push(
+ ...issuesResult.openIssues.slice(0, 20).map((issue: GitHubIssue) => ({
+ number: issue.number,
+ title: issue.title,
+ url: issue.url,
+ author: issue.author,
+ }))
+ );
+ }
+ }
+
+ // Fetch PRs
+ if (api.github?.listPRs) {
+ const prsResult = await api.github.listPRs(projectPath);
+ if (prsResult.success && prsResult.openPRs) {
+ // Map to cache format
+ fetchedPrs.push(
+ ...prsResult.openPRs.slice(0, 20).map((pr: GitHubPR) => ({
+ number: pr.number,
+ title: pr.title,
+ url: pr.url,
+ author: pr.author,
+ }))
+ );
+ }
+ }
+
+ setGitHubCache(projectPath, { issues: fetchedIssues, prs: fetchedPrs });
+ } catch (error) {
+ console.error('Error fetching GitHub data:', error);
+ // On error, just mark as not fetching but keep existing cache
+ setGitHubCacheFetching(projectPath, false);
+ } finally {
+ fetchingRef.current = false;
+ }
+ },
+ [projectPath, setGitHubCache, setGitHubCacheFetching]
+ );
+
+ // Initial fetch or refresh if cache is stale
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const isCacheStale = !lastFetched || Date.now() - lastFetched > CACHE_DURATION_MS;
+
+ if (!hasCache) {
+ // No cache, do initial fetch (show spinner)
+ fetchData(false);
+ } else if (isCacheStale && !isFetching) {
+ // Cache is stale, refresh in background (no spinner, show cached data)
+ fetchData(true);
+ }
+ }, [projectPath, hasCache, lastFetched, isFetching, fetchData]);
+
+ // Auto-refresh interval
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const interval = setInterval(() => {
+ const currentCache = getGitHubCache(projectPath);
+ const isStale =
+ !currentCache?.lastFetched || Date.now() - currentCache.lastFetched > CACHE_DURATION_MS;
+
+ if (isStale && !fetchingRef.current) {
+ fetchData(true);
+ }
+ }, CACHE_DURATION_MS);
+
+ return () => clearInterval(interval);
+ }, [projectPath, getGitHubCache, fetchData]);
+
+ const handleRefresh = useCallback(() => {
+ fetchData(false);
+ }, [fetchData]);
+
+ const handleOpenInGitHub = useCallback((url: string) => {
+ const api = getElectronAPI();
+ api.openExternalLink(url);
+ }, []);
+
+ // Only show loading spinner if no cached data AND fetching
+ if (!hasCache && isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with tabs */}
+
+
+ setActiveTab('issues')}
+ className={cn(
+ 'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
+ activeTab === 'issues'
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+
+ Issues ({issues.length})
+
+ setActiveTab('prs')}
+ className={cn(
+ 'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
+ activeTab === 'prs'
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground'
+ )}
+ >
+
+ PRs ({prs.length})
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+ {activeTab === 'issues' ? (
+ issues.length === 0 ? (
+
No open issues
+ ) : (
+ issues.map((issue) => (
+
handleOpenInGitHub(issue.url)}
+ >
+
+
+
{issue.title}
+
+ #{issue.number} opened by {issue.author?.login}
+
+
+
+
+ ))
+ )
+ ) : prs.length === 0 ? (
+
No open pull requests
+ ) : (
+ prs.map((pr) => (
+
handleOpenInGitHub(pr.url)}
+ >
+
+
+
{pr.title}
+
+ #{pr.number} by {pr.author?.login}
+
+
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/ideation-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/ideation-panel.tsx
new file mode 100644
index 00000000..42637040
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/ideation-panel.tsx
@@ -0,0 +1,592 @@
+/**
+ * IdeationPanel - Bottom dock panel for brainstorming and idea generation
+ * Embeds the full ideation flow: dashboard, category selection, and prompt selection
+ */
+
+import { useState, useMemo, useCallback } from 'react';
+import {
+ Sparkles,
+ Lightbulb,
+ ArrowLeft,
+ Loader2,
+ AlertCircle,
+ Plus,
+ X,
+ ChevronRight,
+ Zap,
+ Palette,
+ Code,
+ TrendingUp,
+ Cpu,
+ Shield,
+ Gauge,
+ Accessibility,
+ BarChart3,
+ CheckCircle2,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
+import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
+import { getElectronAPI } from '@/lib/electron';
+import { toast } from 'sonner';
+import type { IdeaCategory, IdeationPrompt, AnalysisSuggestion } from '@automaker/types';
+
+type PanelMode = 'dashboard' | 'categories' | 'prompts';
+
+const iconMap: Record = {
+ Zap,
+ Palette,
+ Code,
+ TrendingUp,
+ Cpu,
+ Shield,
+ Gauge,
+ Accessibility,
+ BarChart3,
+};
+
+// Suggestion card for dashboard view
+function SuggestionCard({
+ suggestion,
+ job,
+ onAccept,
+ onRemove,
+ isAdding,
+}: {
+ suggestion: AnalysisSuggestion;
+ job: GenerationJob;
+ onAccept: () => void;
+ onRemove: () => void;
+ isAdding: boolean;
+}) {
+ return (
+
+
+
+ {/* Title and remove button */}
+
+
{suggestion.title}
+
+
+
+
+ {/* Badges */}
+
+
+ {suggestion.priority}
+
+
+ {job.prompt.title}
+
+
+ {/* Description */}
+
{suggestion.description}
+ {/* Accept button */}
+
+ {isAdding ? (
+
+ ) : (
+ <>
+
+ Accept
+ >
+ )}
+
+
+
+
+ );
+}
+
+// Generating card for active jobs
+function GeneratingCard({ job }: { job: GenerationJob }) {
+ const { removeJob } = useIdeationStore();
+ const isError = job.status === 'error';
+
+ return (
+
+
+
+
+ {isError ? (
+
+ ) : (
+
+ )}
+
+
{job.prompt.title}
+
+ {isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
+
+
+
+
removeJob(job.id)}
+ className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
+ >
+
+
+
+
+
+ );
+}
+
+// Dashboard view - shows generated ideas
+function DashboardView({ onGenerateIdeas }: { onGenerateIdeas: () => void }) {
+ const currentProject = useAppStore((s) => s.currentProject);
+ const generationJobs = useIdeationStore((s) => s.generationJobs);
+ const removeSuggestionFromJob = useIdeationStore((s) => s.removeSuggestionFromJob);
+ const [addingId, setAddingId] = useState(null);
+
+ const projectJobs = useMemo(
+ () =>
+ currentProject?.path
+ ? generationJobs.filter((job) => job.projectPath === currentProject.path)
+ : [],
+ [generationJobs, currentProject?.path]
+ );
+
+ const { activeJobs, readyJobs } = useMemo(() => {
+ const active: GenerationJob[] = [];
+ const ready: GenerationJob[] = [];
+
+ for (const job of projectJobs) {
+ if (job.status === 'generating' || job.status === 'error') {
+ active.push(job);
+ } else if (job.status === 'ready' && job.suggestions.length > 0) {
+ ready.push(job);
+ }
+ }
+
+ return { activeJobs: active, readyJobs: ready };
+ }, [projectJobs]);
+
+ const allSuggestions = useMemo(
+ () => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))),
+ [readyJobs]
+ );
+
+ const handleAccept = async (suggestion: AnalysisSuggestion, jobId: string) => {
+ if (!currentProject?.path) {
+ toast.error('No project selected');
+ return;
+ }
+
+ setAddingId(suggestion.id);
+
+ try {
+ const api = getElectronAPI();
+ const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion);
+
+ if (result?.success) {
+ toast.success(`Added "${suggestion.title}" to board`);
+ removeSuggestionFromJob(jobId, suggestion.id);
+ } else {
+ toast.error(result?.error || 'Failed to add to board');
+ }
+ } catch (error) {
+ console.error('Failed to add to board:', error);
+ toast.error((error as Error).message);
+ } finally {
+ setAddingId(null);
+ }
+ };
+
+ const handleRemove = (suggestionId: string, jobId: string) => {
+ removeSuggestionFromJob(jobId, suggestionId);
+ toast.info('Idea removed');
+ };
+
+ const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0;
+
+ return (
+
+ {/* Active jobs */}
+ {activeJobs.map((job) => (
+
+ ))}
+
+ {/* Suggestions */}
+ {allSuggestions.map(({ suggestion, job }) => (
+
handleAccept(suggestion, job.id)}
+ onRemove={() => handleRemove(suggestion.id, job.id)}
+ isAdding={addingId === suggestion.id}
+ />
+ ))}
+
+ {/* Empty state */}
+ {isEmpty && (
+
+
+
No ideas yet
+
+ Generate ideas by selecting a category and prompt
+
+
+
+ Generate Ideas
+
+
+ )}
+
+ {/* Generate more button */}
+ {!isEmpty && (
+
+
+ Generate More Ideas
+
+ )}
+
+ );
+}
+
+// Category grid view
+function CategoryGridView({
+ onSelect,
+ onBack,
+}: {
+ onSelect: (category: IdeaCategory) => void;
+ onBack: () => void;
+}) {
+ const { categories, isLoading, error } = useGuidedPrompts();
+
+ return (
+
+
+
+ Back to dashboard
+
+
+ {isLoading && (
+
+
+ Loading categories...
+
+ )}
+
+ {error && (
+
+
Failed to load categories: {error}
+
+ )}
+
+ {!isLoading && !error && (
+
+ {categories.map((category) => {
+ const Icon = iconMap[category.icon] || Zap;
+ return (
+
onSelect(category.id)}
+ >
+
+
+
+
+
+
+
{category.name}
+
+ {category.description}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+// Prompt list view
+function PromptListView({
+ category,
+ onBack,
+ onDone,
+}: {
+ category: IdeaCategory;
+ onBack: () => void;
+ onDone: () => void;
+}) {
+ const currentProject = useAppStore((s) => s.currentProject);
+ const generationJobs = useIdeationStore((s) => s.generationJobs);
+ const addGenerationJob = useIdeationStore((s) => s.addGenerationJob);
+ const updateJobStatus = useIdeationStore((s) => s.updateJobStatus);
+ const [loadingPromptId, setLoadingPromptId] = useState(null);
+ const [startedPrompts, setStartedPrompts] = useState>(new Set());
+
+ const { getPromptsByCategory, getCategoryById, isLoading, error } = useGuidedPrompts();
+ const prompts = getPromptsByCategory(category);
+ const categoryInfo = getCategoryById(category);
+
+ const projectJobs = useMemo(
+ () =>
+ currentProject?.path
+ ? generationJobs.filter((job) => job.projectPath === currentProject.path)
+ : [],
+ [generationJobs, currentProject?.path]
+ );
+
+ const generatingPromptIds = useMemo(
+ () => new Set(projectJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id)),
+ [projectJobs]
+ );
+
+ const handleSelectPrompt = async (prompt: IdeationPrompt) => {
+ if (!currentProject?.path) {
+ toast.error('No project selected');
+ return;
+ }
+
+ if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
+
+ setLoadingPromptId(prompt.id);
+ const jobId = addGenerationJob(currentProject.path, prompt);
+ setStartedPrompts((prev) => new Set(prev).add(prompt.id));
+
+ toast.info(`Generating ideas for "${prompt.title}"...`);
+ onDone(); // Navigate back to dashboard
+
+ try {
+ const api = getElectronAPI();
+ const result = await api.ideation?.generateSuggestions(
+ currentProject.path,
+ prompt.id,
+ category
+ );
+
+ if (result?.success && result.suggestions) {
+ updateJobStatus(jobId, 'ready', result.suggestions);
+ toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`);
+ } else {
+ updateJobStatus(
+ jobId,
+ 'error',
+ undefined,
+ result?.error || 'Failed to generate suggestions'
+ );
+ toast.error(result?.error || 'Failed to generate suggestions');
+ }
+ } catch (error) {
+ console.error('Failed to generate suggestions:', error);
+ updateJobStatus(jobId, 'error', undefined, (error as Error).message);
+ toast.error((error as Error).message);
+ } finally {
+ setLoadingPromptId(null);
+ }
+ };
+
+ return (
+
+
+
+ Back to categories
+
+
+ {categoryInfo && (
+
+ Select a prompt from{' '}
+ {categoryInfo.name}
+
+ )}
+
+ {isLoading && (
+
+
+ Loading prompts...
+
+ )}
+
+ {error && (
+
+
Failed to load prompts: {error}
+
+ )}
+
+ {!isLoading && !error && (
+
+ {prompts.map((prompt) => {
+ const isLoading = loadingPromptId === prompt.id;
+ const isGenerating = generatingPromptIds.has(prompt.id);
+ const isStarted = startedPrompts.has(prompt.id);
+ const isDisabled = loadingPromptId !== null || isGenerating;
+
+ return (
+
!isDisabled && handleSelectPrompt(prompt)}
+ >
+
+
+
+ {isLoading || isGenerating ? (
+
+ ) : isStarted ? (
+
+ ) : (
+
+ )}
+
+
+
{prompt.title}
+
+ {prompt.description}
+
+ {(isLoading || isGenerating) && (
+
Generating...
+ )}
+ {isStarted && !isGenerating && (
+
Generated - check dashboard
+ )}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export function IdeationPanel() {
+ const { currentProject } = useAppStore();
+ const [mode, setMode] = useState('dashboard');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ const handleGenerateIdeas = useCallback(() => {
+ setMode('categories');
+ setSelectedCategory(null);
+ }, []);
+
+ const handleSelectCategory = useCallback((category: IdeaCategory) => {
+ setSelectedCategory(category);
+ setMode('prompts');
+ }, []);
+
+ const handleBackFromCategories = useCallback(() => {
+ setMode('dashboard');
+ }, []);
+
+ const handleBackFromPrompts = useCallback(() => {
+ setMode('categories');
+ setSelectedCategory(null);
+ }, []);
+
+ const handlePromptDone = useCallback(() => {
+ setMode('dashboard');
+ setSelectedCategory(null);
+ }, []);
+
+ if (!currentProject) {
+ return (
+
+
+
+
Open a project to start brainstorming
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Ideation
+ {mode === 'dashboard' && (
+ - Review and accept ideas
+ )}
+ {mode === 'categories' && (
+ - Select a category
+ )}
+ {mode === 'prompts' && selectedCategory && (
+ - Select a prompt
+ )}
+
+ {mode === 'dashboard' && (
+
+
+ Generate
+
+ )}
+
+
+ {/* Content */}
+ {mode === 'dashboard' &&
}
+ {mode === 'categories' && (
+
+ )}
+ {mode === 'prompts' && selectedCategory && (
+
+ )}
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/index.ts b/apps/ui/src/components/layout/bottom-dock/panels/index.ts
new file mode 100644
index 00000000..08cb4a53
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/index.ts
@@ -0,0 +1,7 @@
+export { GitHubPanel } from './github-panel';
+export { AgentsPanel } from './agents-panel';
+export { SpecPanel } from './spec-panel';
+export { ContextPanel } from './context-panel';
+export { TerminalPanelDock } from './terminal-panel';
+export { ChatPanel } from './chat-panel';
+export { IdeationPanel } from './ideation-panel';
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
new file mode 100644
index 00000000..387a9bba
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx
@@ -0,0 +1,123 @@
+import { useState, useEffect, useCallback } from 'react';
+import { FileText, Loader2, Save } from 'lucide-react';
+import { getElectronAPI } from '@/lib/electron';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { cn } from '@/lib/utils';
+
+export function SpecPanel() {
+ const { currentProject } = useAppStore();
+ const [specContent, setSpecContent] = useState('');
+ const [originalContent, setOriginalContent] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ const hasChanges = specContent !== originalContent;
+
+ const loadSpec = useCallback(async () => {
+ if (!currentProject?.path) return;
+
+ setLoading(true);
+ try {
+ const api = getElectronAPI();
+ if (api.spec?.read) {
+ const result = await api.spec.read(currentProject.path);
+ if (result.success && result.content !== undefined) {
+ setSpecContent(result.content);
+ setOriginalContent(result.content);
+ }
+ }
+ } catch (error) {
+ console.error('Error loading spec:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, [currentProject?.path]);
+
+ useEffect(() => {
+ loadSpec();
+ }, [loadSpec]);
+
+ const handleSave = useCallback(async () => {
+ if (!currentProject?.path || !hasChanges) return;
+
+ setSaving(true);
+ try {
+ const api = getElectronAPI();
+ if (api.spec?.write) {
+ const result = await api.spec.write(currentProject.path, specContent);
+ if (result.success) {
+ setOriginalContent(specContent);
+ toast.success('Spec saved');
+ } else {
+ toast.error('Failed to save spec');
+ }
+ }
+ } catch (error) {
+ toast.error('Failed to save spec');
+ } finally {
+ setSaving(false);
+ }
+ }, [currentProject?.path, specContent, hasChanges]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ App Specification
+ {hasChanges && Unsaved changes}
+
+ {hasChanges && (
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ Save
+
+ )}
+
+
+ {/* Content */}
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/bottom-dock/panels/terminal-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/terminal-panel.tsx
new file mode 100644
index 00000000..44be3e3c
--- /dev/null
+++ b/apps/ui/src/components/layout/bottom-dock/panels/terminal-panel.tsx
@@ -0,0 +1,551 @@
+import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
+import {
+ Terminal,
+ Plus,
+ Loader2,
+ AlertCircle,
+ SplitSquareHorizontal,
+ SplitSquareVertical,
+ X,
+} from 'lucide-react';
+import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
+import { useShallow } from 'zustand/react/shallow';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { TerminalPanel as XTermPanel } from '@/components/views/terminal-view/terminal-panel';
+import { TerminalErrorBoundary } from '@/components/views/terminal-view/terminal-error-boundary';
+import { apiFetch, apiGet, apiDeleteRaw } from '@/lib/api-fetch';
+import { createLogger } from '@automaker/utils/logger';
+import { toast } from 'sonner';
+import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
+
+const logger = createLogger('DockTerminal');
+
+interface TerminalStatus {
+ enabled: boolean;
+ passwordRequired: boolean;
+}
+
+const CREATE_COOLDOWN_MS = 500;
+
+export function TerminalPanelDock() {
+ // Use useShallow for terminal state to prevent unnecessary re-renders
+ const terminalState = useAppStore(useShallow((state) => state.terminalState));
+
+ const {
+ tabs,
+ activeTabId,
+ activeSessionId,
+ authToken,
+ isUnlocked,
+ defaultFontSize,
+ maximizedSessionId,
+ } = terminalState;
+
+ // Get stable action references (these don't change between renders)
+ const currentProject = useAppStore((state) => state.currentProject);
+ const setTerminalUnlocked = useAppStore((state) => state.setTerminalUnlocked);
+ const addTerminalToLayout = useAppStore((state) => state.addTerminalToLayout);
+ const removeTerminalFromLayout = useAppStore((state) => state.removeTerminalFromLayout);
+ const setActiveTerminalSession = useAppStore((state) => state.setActiveTerminalSession);
+ const addTerminalTab = useAppStore((state) => state.addTerminalTab);
+ const removeTerminalTab = useAppStore((state) => state.removeTerminalTab);
+ const setActiveTerminalTab = useAppStore((state) => state.setActiveTerminalTab);
+ const setTerminalPanelFontSize = useAppStore((state) => state.setTerminalPanelFontSize);
+ const toggleTerminalMaximized = useAppStore((state) => state.toggleTerminalMaximized);
+ const updateTerminalPanelSizes = useAppStore((state) => state.updateTerminalPanelSizes);
+
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [status, setStatus] = useState(null);
+ const isCreatingRef = useRef(false);
+ const lastCreateTimeRef = useRef(0);
+
+ // Refs to stabilize callbacks and prevent cascading re-renders
+ const createTerminalRef = useRef<
+ ((direction?: 'horizontal' | 'vertical', targetSessionId?: string) => Promise) | null
+ >(null);
+ const killTerminalRef = useRef<((sessionId: string) => Promise) | null>(null);
+ const createTerminalInNewTabRef = useRef<(() => Promise) | null>(null);
+ const navigateToTerminalRef = useRef<
+ ((direction: 'up' | 'down' | 'left' | 'right') => void) | null
+ >(null);
+
+ // Fetch terminal status
+ const fetchStatus = useCallback(async () => {
+ try {
+ const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
+ '/api/terminal/status'
+ );
+ if (data.success && data.data) {
+ setStatus(data.data);
+ if (!data.data.passwordRequired) {
+ setTerminalUnlocked(true);
+ }
+ } else {
+ setError(data.error || 'Failed to get terminal status');
+ }
+ } catch (err) {
+ setError('Failed to connect to server');
+ logger.error('Status fetch error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [setTerminalUnlocked]);
+
+ useEffect(() => {
+ fetchStatus();
+ }, [fetchStatus]);
+
+ // Helper to check if terminal creation should be debounced
+ const canCreateTerminal = (): boolean => {
+ const now = Date.now();
+ if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
+ return false;
+ }
+ lastCreateTimeRef.current = now;
+ isCreatingRef.current = true;
+ return true;
+ };
+
+ // Create a new terminal session
+ const createTerminal = useCallback(
+ async (direction?: 'horizontal' | 'vertical', targetSessionId?: string) => {
+ if (!canCreateTerminal()) return;
+
+ try {
+ const headers: Record = {};
+ if (authToken) {
+ headers['X-Terminal-Token'] = authToken;
+ }
+
+ const response = await apiFetch('/api/terminal/sessions', 'POST', {
+ headers,
+ body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ addTerminalToLayout(data.data.id, direction, targetSessionId);
+ } else {
+ if (response.status === 429 || data.error?.includes('Maximum')) {
+ toast.error('Terminal session limit reached', {
+ description: data.details || 'Please close unused terminals.',
+ });
+ } else {
+ toast.error('Failed to create terminal', { description: data.error });
+ }
+ }
+ } catch (err) {
+ logger.error('Create session error:', err);
+ toast.error('Failed to create terminal');
+ } finally {
+ isCreatingRef.current = false;
+ }
+ },
+ [currentProject?.path, authToken, addTerminalToLayout]
+ );
+
+ // Create terminal in new tab
+ const createTerminalInNewTab = useCallback(async () => {
+ if (!canCreateTerminal()) return;
+
+ const tabId = addTerminalTab();
+ try {
+ const headers: Record = {};
+ if (authToken) {
+ headers['X-Terminal-Token'] = authToken;
+ }
+
+ const response = await apiFetch('/api/terminal/sessions', 'POST', {
+ headers,
+ body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ const { addTerminalToTab } = useAppStore.getState();
+ addTerminalToTab(data.data.id, tabId);
+ } else {
+ removeTerminalTab(tabId);
+ toast.error('Failed to create terminal', { description: data.error });
+ }
+ } catch (err) {
+ logger.error('Create session error:', err);
+ removeTerminalTab(tabId);
+ toast.error('Failed to create terminal');
+ } finally {
+ isCreatingRef.current = false;
+ }
+ }, [currentProject?.path, authToken, addTerminalTab, removeTerminalTab]);
+
+ // Kill a terminal session
+ const killTerminal = useCallback(
+ async (sessionId: string) => {
+ try {
+ const headers: Record = {};
+ if (authToken) {
+ headers['X-Terminal-Token'] = authToken;
+ }
+
+ await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
+ removeTerminalFromLayout(sessionId);
+ } catch (err) {
+ logger.error('Kill session error:', err);
+ removeTerminalFromLayout(sessionId);
+ }
+ },
+ [authToken, removeTerminalFromLayout]
+ );
+
+ // Kill all terminals in a tab
+ const killTerminalTab = useCallback(
+ async (tabId: string) => {
+ const tab = tabs.find((t) => t.id === tabId);
+ if (!tab) return;
+
+ const collectSessionIds = (node: TerminalPanelContent | null): string[] => {
+ if (!node) return [];
+ if (node.type === 'terminal') return [node.sessionId];
+ return node.panels.flatMap(collectSessionIds);
+ };
+
+ const sessionIds = collectSessionIds(tab.layout);
+ const headers: Record = {};
+ if (authToken) {
+ headers['X-Terminal-Token'] = authToken;
+ }
+
+ await Promise.all(
+ sessionIds.map(async (sessionId) => {
+ try {
+ await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
+ } catch (err) {
+ logger.error(`Failed to kill session ${sessionId}:`, err);
+ }
+ })
+ );
+
+ removeTerminalTab(tabId);
+ },
+ [tabs, authToken, removeTerminalTab]
+ );
+
+ // Get panel key for stable rendering
+ const getPanelKey = (panel: TerminalPanelContent): string => {
+ if (panel.type === 'terminal') return panel.sessionId;
+ return panel.id;
+ };
+
+ // Navigate between terminals
+ const navigateToTerminal = useCallback(
+ (direction: 'up' | 'down' | 'left' | 'right') => {
+ const activeTab = tabs.find((t) => t.id === activeTabId);
+ if (!activeTab?.layout) return;
+
+ const currentSessionId = activeSessionId;
+ if (!currentSessionId) return;
+
+ const getTerminalIds = (panel: TerminalPanelContent): string[] => {
+ if (panel.type === 'terminal') return [panel.sessionId];
+ return panel.panels.flatMap(getTerminalIds);
+ };
+
+ const terminalIds = getTerminalIds(activeTab.layout);
+ const currentIndex = terminalIds.indexOf(currentSessionId);
+ if (currentIndex === -1) return;
+
+ let nextIndex = currentIndex;
+ if (direction === 'right' || direction === 'down') {
+ nextIndex = (currentIndex + 1) % terminalIds.length;
+ } else {
+ nextIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length;
+ }
+
+ if (terminalIds[nextIndex]) {
+ setActiveTerminalSession(terminalIds[nextIndex]);
+ }
+ },
+ [tabs, activeTabId, activeSessionId, setActiveTerminalSession]
+ );
+
+ // Keep refs updated with latest callbacks
+ createTerminalRef.current = createTerminal;
+ killTerminalRef.current = killTerminal;
+ createTerminalInNewTabRef.current = createTerminalInNewTab;
+ navigateToTerminalRef.current = navigateToTerminal;
+
+ // Render panel content recursively - use refs for callbacks to prevent re-renders
+ const renderPanelContent = useCallback(
+ (content: TerminalPanelContent, activeTabData: TerminalTab): React.ReactNode => {
+ if (content.type === 'terminal') {
+ const terminalFontSize = content.fontSize ?? defaultFontSize;
+ return (
+ {
+ killTerminalRef.current?.(content.sessionId);
+ createTerminalRef.current?.();
+ }}
+ >
+ setActiveTerminalSession(content.sessionId)}
+ onClose={() => killTerminalRef.current?.(content.sessionId)}
+ onSplitHorizontal={() => createTerminalRef.current?.('horizontal', content.sessionId)}
+ onSplitVertical={() => createTerminalRef.current?.('vertical', content.sessionId)}
+ onNewTab={() => createTerminalInNewTabRef.current?.()}
+ onNavigateUp={() => navigateToTerminalRef.current?.('up')}
+ onNavigateDown={() => navigateToTerminalRef.current?.('down')}
+ onNavigateLeft={() => navigateToTerminalRef.current?.('left')}
+ onNavigateRight={() => navigateToTerminalRef.current?.('right')}
+ onSessionInvalid={() => killTerminalRef.current?.(content.sessionId)}
+ fontSize={terminalFontSize}
+ onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
+ isMaximized={maximizedSessionId === content.sessionId}
+ onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
+ />
+
+ );
+ }
+
+ const isHorizontal = content.direction === 'horizontal';
+ const defaultSizePerPanel = 100 / content.panels.length;
+
+ const handleLayoutChange = (sizes: number[]) => {
+ const panelKeys = content.panels.map(getPanelKey);
+ updateTerminalPanelSizes(activeTabData.id, panelKeys, sizes);
+ };
+
+ return (
+
+ {content.panels.map((panel, index) => {
+ const panelSize =
+ panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
+ const panelKey = getPanelKey(panel);
+ return (
+
+ {index > 0 && (
+
+ )}
+
+ {renderPanelContent(panel, activeTabData)}
+
+
+ );
+ })}
+
+ );
+ },
+ [
+ defaultFontSize,
+ authToken,
+ activeSessionId,
+ maximizedSessionId,
+ setActiveTerminalSession,
+ setTerminalPanelFontSize,
+ toggleTerminalMaximized,
+ updateTerminalPanelSizes,
+ ]
+ );
+
+ const activeTab = tabs.find((t) => t.id === activeTabId);
+
+ // Header component for all states
+ const Header = ({ children }: { children?: React.ReactNode }) => (
+
+
+
+ Terminal
+
+ {children &&
{children}
}
+
+ );
+
+ // Loading state
+ if (loading) {
+ return (
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
+
+
+
{error}
+
+ Retry
+
+
+
+
+ );
+ }
+
+ // Password required
+ if (status?.passwordRequired && !isUnlocked) {
+ return (
+
+
+
+
+
+
Terminal requires authentication
+
Password required to use terminal
+
+
+
+ );
+ }
+
+ // No project selected
+ if (!currentProject) {
+ return (
+
+
+
+
+
+
No project selected
+
+
+
+ );
+ }
+
+ // No terminals yet
+ if (tabs.length === 0) {
+ return (
+
+
+ createTerminal()}
+ title="New terminal"
+ >
+
+
+
+
+
+
+
No terminals open
+
createTerminal()}>
+
+ New Terminal
+
+
+
+
+ );
+ }
+
+ // Terminal view with tabs
+ return (
+
+ {/* Tab bar */}
+
+ {tabs.map((tab) => (
+
setActiveTerminalTab(tab.id)}
+ className={cn(
+ 'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors shrink-0',
+ tab.id === activeTabId
+ ? 'bg-accent text-accent-foreground'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
+ )}
+ >
+
+ {tab.name}
+ {
+ e.stopPropagation();
+ killTerminalTab(tab.id);
+ }}
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ createTerminal('horizontal')}
+ title="Split Right"
+ >
+
+
+ createTerminal('vertical')}
+ title="Split Down"
+ >
+
+
+
+
+
+ {/* Terminal content */}
+
+ {activeTab?.layout ? (
+ renderPanelContent(activeTab.layout, activeTab)
+ ) : (
+
+
+
+
No terminal in this tab
+
createTerminal()}
+ >
+
+ Add Terminal
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/top-bar/index.ts b/apps/ui/src/components/layout/top-bar/index.ts
new file mode 100644
index 00000000..7c13f8e8
--- /dev/null
+++ b/apps/ui/src/components/layout/top-bar/index.ts
@@ -0,0 +1,4 @@
+export { TopBar } from './top-bar';
+export { PinnedProjects } from './pinned-projects';
+export { ProjectSwitcher } from './project-switcher';
+export { TopBarActions } from './top-bar-actions';
diff --git a/apps/ui/src/components/layout/top-bar/pinned-projects.tsx b/apps/ui/src/components/layout/top-bar/pinned-projects.tsx
new file mode 100644
index 00000000..8e590a5e
--- /dev/null
+++ b/apps/ui/src/components/layout/top-bar/pinned-projects.tsx
@@ -0,0 +1,128 @@
+import { useCallback } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import type { Project } from '@/lib/electron';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu';
+import { Star, Settings, Trash2 } from 'lucide-react';
+
+interface PinnedProjectsProps {
+ pinnedProjects: Project[];
+ currentProject: Project | null;
+}
+
+export function PinnedProjects({ pinnedProjects, currentProject }: PinnedProjectsProps) {
+ const navigate = useNavigate();
+ const { setCurrentProject, unpinProject, moveProjectToTrash } = useAppStore();
+
+ const handleProjectClick = useCallback(
+ (project: Project) => {
+ setCurrentProject(project);
+ navigate({ to: '/board' });
+ },
+ [setCurrentProject, navigate]
+ );
+
+ const handleUnpin = useCallback(
+ (projectId: string) => {
+ unpinProject(projectId);
+ },
+ [unpinProject]
+ );
+
+ const handleProjectSettings = useCallback(
+ (project: Project) => {
+ setCurrentProject(project);
+ navigate({ to: '/settings' });
+ },
+ [setCurrentProject, navigate]
+ );
+
+ const handleRemoveProject = useCallback(
+ (projectId: string) => {
+ moveProjectToTrash(projectId);
+ },
+ [moveProjectToTrash]
+ );
+
+ if (pinnedProjects.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {pinnedProjects.map((project) => {
+ const isActive = currentProject?.id === project.id;
+ // TODO: Get running agent count from store
+ const runningCount = 0;
+
+ return (
+
+
+
+
+ handleProjectClick(project)}
+ className={cn(
+ 'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium',
+ 'transition-all duration-200',
+ 'hover:bg-accent/50',
+ isActive && 'bg-accent text-accent-foreground',
+ !isActive && 'text-muted-foreground'
+ )}
+ >
+ {project.name}
+ {runningCount > 0 && (
+
+ )}
+
+
+
+ {project.name}
+ {project.path}
+ {runningCount > 0 && (
+
+ {runningCount} agent{runningCount > 1 ? 's' : ''} running
+
+ )}
+
+
+
+
+ handleProjectClick(project)}>Open
+
+ handleUnpin(project.id)}>
+
+ Unpin from bar
+
+ handleProjectSettings(project)}>
+
+ Project Settings
+
+
+ handleRemoveProject(project.id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Remove Project
+
+
+
+ );
+ })}
+
+ {/* Separator after pinned projects */}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/top-bar/project-switcher.tsx b/apps/ui/src/components/layout/top-bar/project-switcher.tsx
new file mode 100644
index 00000000..1fbb90f3
--- /dev/null
+++ b/apps/ui/src/components/layout/top-bar/project-switcher.tsx
@@ -0,0 +1,202 @@
+import { useCallback } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import type { Project } from '@/lib/electron';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ DropdownMenuLabel,
+} from '@/components/ui/dropdown-menu';
+import { ChevronDown, Star, Plus, FolderOpen, Check } from 'lucide-react';
+
+interface ProjectSwitcherProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ currentProject: Project | null;
+ projects: Project[];
+ pinnedProjectIds: string[];
+ onNewProject: () => void;
+ onOpenFolder: () => void;
+ showCurrentProjectName?: boolean;
+}
+
+export function ProjectSwitcher({
+ isOpen,
+ onOpenChange,
+ currentProject,
+ projects,
+ pinnedProjectIds,
+ onNewProject,
+ onOpenFolder,
+ showCurrentProjectName = true,
+}: ProjectSwitcherProps) {
+ const navigate = useNavigate();
+ const { setCurrentProject, pinProject, unpinProject } = useAppStore();
+
+ const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
+ const unpinnedProjects = projects.filter((p) => !pinnedProjectIds.includes(p.id));
+
+ const handleSelectProject = useCallback(
+ (project: Project) => {
+ setCurrentProject(project);
+ navigate({ to: '/board' });
+ onOpenChange(false);
+ },
+ [setCurrentProject, navigate, onOpenChange]
+ );
+
+ const handleTogglePin = useCallback(
+ (e: React.MouseEvent, projectId: string) => {
+ e.stopPropagation();
+ if (pinnedProjectIds.includes(projectId)) {
+ unpinProject(projectId);
+ } else {
+ pinProject(projectId);
+ }
+ },
+ [pinnedProjectIds, pinProject, unpinProject]
+ );
+
+ const handleNewProject = useCallback(() => {
+ onOpenChange(false);
+ onNewProject();
+ }, [onOpenChange, onNewProject]);
+
+ const handleOpenFolder = useCallback(() => {
+ onOpenChange(false);
+ onOpenFolder();
+ }, [onOpenChange, onOpenFolder]);
+
+ const handleAllProjects = useCallback(() => {
+ onOpenChange(false);
+ navigate({ to: '/dashboard' });
+ }, [onOpenChange, navigate]);
+
+ // TODO: Get running agent counts from store
+ const getRunningCount = (projectId: string) => 0;
+
+ // Determine if we should show the current project name in the trigger
+ // Don't show if it's already visible as a pinned project
+ const currentProjectIsPinned = currentProject && pinnedProjectIds.includes(currentProject.id);
+ const shouldShowProjectName = showCurrentProjectName && currentProject && !currentProjectIsPinned;
+
+ return (
+
+
+
+ {shouldShowProjectName && (
+ {currentProject.name}
+ )}
+
+
+
+
+ {/* Pinned Projects */}
+ {pinnedProjects.length > 0 && (
+ <>
+ Pinned
+ {pinnedProjects.map((project) => {
+ const isActive = currentProject?.id === project.id;
+ const runningCount = getRunningCount(project.id);
+
+ return (
+ handleSelectProject(project)}
+ className="flex items-center justify-between"
+ >
+
+ {isActive && }
+ {project.name}
+
+
+ {runningCount > 0 && (
+
+
+ {runningCount}
+
+ )}
+ handleTogglePin(e, project.id)}
+ className="p-0.5 hover:bg-accent rounded"
+ >
+
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* Other Projects */}
+ {unpinnedProjects.length > 0 && (
+ <>
+
+ Other Projects
+
+ {unpinnedProjects.map((project) => {
+ const isActive = currentProject?.id === project.id;
+ const runningCount = getRunningCount(project.id);
+
+ return (
+ handleSelectProject(project)}
+ className="flex items-center justify-between"
+ >
+
+ {isActive && }
+ {project.name}
+
+
+ {runningCount > 0 && (
+
+
+ {runningCount}
+
+ )}
+ handleTogglePin(e, project.id)}
+ className="p-0.5 hover:bg-accent rounded"
+ >
+
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+ {/* Actions */}
+
+
+ New Project
+
+
+
+ Open Folder
+
+
+
+
+ All Projects
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/top-bar/top-bar-actions.tsx b/apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
new file mode 100644
index 00000000..9691c55d
--- /dev/null
+++ b/apps/ui/src/components/layout/top-bar/top-bar-actions.tsx
@@ -0,0 +1,389 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { useNavigate, useLocation } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import type { Project } from '@/lib/electron';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Slider } from '@/components/ui/slider';
+import { Switch } from '@/components/ui/switch';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import {
+ Settings,
+ Bot,
+ Bell,
+ Wand2,
+ GitBranch,
+ Search,
+ X,
+ ImageIcon,
+ Archive,
+ Minimize2,
+ Square,
+ Maximize2,
+ Columns3,
+ Network,
+} from 'lucide-react';
+import { SettingsDialog } from '@/components/dialogs/settings-dialog';
+interface TopBarActionsProps {
+ currentProject: Project | null;
+}
+
+export function TopBarActions({ currentProject }: TopBarActionsProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const {
+ getAutoModeState,
+ setAutoModeRunning,
+ maxConcurrency,
+ setMaxConcurrency,
+ worktreePanelCollapsed,
+ setWorktreePanelCollapsed,
+ boardSearchQuery,
+ setBoardSearchQuery,
+ kanbanCardDetailLevel,
+ setKanbanCardDetailLevel,
+ boardViewMode,
+ setBoardViewMode,
+ } = useAppStore();
+
+ const [showAgentSettings, setShowAgentSettings] = useState(false);
+ const [showSettingsDialog, setShowSettingsDialog] = useState(false);
+ const searchInputRef = useRef(null);
+
+ const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
+ const isAutoModeRunning = autoModeState?.isRunning ?? false;
+ const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0;
+
+ const isOnBoardView = location.pathname === '/board';
+
+ // Focus search input when "/" is pressed (only on board view)
+ useEffect(() => {
+ if (!isOnBoardView) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ e.key === '/' &&
+ !(e.target instanceof HTMLInputElement) &&
+ !(e.target instanceof HTMLTextAreaElement)
+ ) {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [isOnBoardView]);
+
+ const handlePlan = useCallback(() => {
+ if (isOnBoardView) {
+ // Dispatch custom event for board-view to handle
+ window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
+ } else {
+ // Navigate to board first, then open plan dialog
+ navigate({ to: '/board' });
+ setTimeout(() => {
+ window.dispatchEvent(new CustomEvent('automaker:open-plan-dialog'));
+ }, 100);
+ }
+ }, [isOnBoardView, navigate]);
+
+ const handleAutoModeToggle = useCallback(
+ (enabled: boolean) => {
+ if (currentProject) {
+ setAutoModeRunning(currentProject.id, enabled);
+ }
+ },
+ [currentProject, setAutoModeRunning]
+ );
+
+ const handleSettings = useCallback(() => {
+ setShowSettingsDialog(true);
+ }, []);
+
+ const handleNotifications = useCallback(() => {
+ // TODO: Open notifications panel
+ }, []);
+
+ const handleShowBoardBackground = useCallback(() => {
+ window.dispatchEvent(new CustomEvent('automaker:open-board-background'));
+ }, []);
+
+ const handleShowCompletedFeatures = useCallback(() => {
+ window.dispatchEvent(new CustomEvent('automaker:open-completed-features'));
+ }, []);
+
+ return (
+
+
+ {currentProject && (
+ <>
+ {/* Worktree Panel Toggle */}
+ {isOnBoardView && (
+
+
+ setWorktreePanelCollapsed(!worktreePanelCollapsed)}
+ className="gap-2"
+ >
+
+ Worktrees
+
+
+
+ {worktreePanelCollapsed ? 'Show worktree panel' : 'Hide worktree panel'}
+
+
+ )}
+
+ {/* Board Controls - only show on board view */}
+ {isOnBoardView && (
+ <>
+
+
+ {/* Search Bar */}
+
+
+ setBoardSearchQuery(e.target.value)}
+ className="h-8 pl-8 pr-8 text-sm border-border"
+ data-testid="topbar-search-input"
+ />
+ {boardSearchQuery ? (
+ setBoardSearchQuery('')}
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
+ aria-label="Clear search"
+ >
+
+
+ ) : (
+
+ /
+
+ )}
+
+
+ {/* View Mode Toggle */}
+
+
+
+ setBoardViewMode('kanban')}
+ className={cn(
+ 'p-1.5 rounded-l-md transition-colors',
+ boardViewMode === 'kanban'
+ ? 'bg-brand-500/20 text-brand-500'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ )}
+ >
+
+
+
+ Kanban Board View
+
+
+
+ setBoardViewMode('graph')}
+ className={cn(
+ 'p-1.5 rounded-r-md transition-colors',
+ boardViewMode === 'graph'
+ ? 'bg-brand-500/20 text-brand-500'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ )}
+ >
+
+
+
+ Dependency Graph View
+
+
+
+ {/* Board Background */}
+
+
+
+
+
+
+ Board Background
+
+
+ {/* Completed Features */}
+
+
+
+
+
+
+ Completed Features
+
+
+ {/* Detail Level Toggle */}
+
+
+
+ setKanbanCardDetailLevel('minimal')}
+ className={cn(
+ 'p-1.5 rounded-l-md transition-colors',
+ kanbanCardDetailLevel === 'minimal'
+ ? 'bg-brand-500/20 text-brand-500'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ )}
+ >
+
+
+
+ Minimal
+
+
+
+ setKanbanCardDetailLevel('standard')}
+ className={cn(
+ 'p-1.5 transition-colors',
+ kanbanCardDetailLevel === 'standard'
+ ? 'bg-brand-500/20 text-brand-500'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ )}
+ >
+
+
+
+ Standard
+
+
+
+ setKanbanCardDetailLevel('detailed')}
+ className={cn(
+ 'p-1.5 rounded-r-md transition-colors',
+ kanbanCardDetailLevel === 'detailed'
+ ? 'bg-brand-500/20 text-brand-500'
+ : 'text-muted-foreground hover:text-foreground hover:bg-accent'
+ )}
+ >
+
+
+
+ Detailed
+
+
+ >
+ )}
+
+
+
+ {/* Agents Control */}
+
+
+ 0 && 'text-green-500')}
+ >
+
+
+ {runningAgentsCount}/{maxConcurrency}
+
+
+
+
+
+
+ Max Agents
+ {maxConcurrency}
+
+
setMaxConcurrency(value[0])}
+ min={1}
+ max={10}
+ step={1}
+ />
+
+ Maximum concurrent agents when auto mode is running
+
+
+
+
+
+ {/* Auto Mode Toggle */}
+
+ Auto
+
+
+
+
+
+ {/* Plan Button */}
+
+
+
+
+ Plan
+
+
+ Plan features with AI
+
+ >
+ )}
+
+ {/* Notifications */}
+
+
+
+
+ {/* Notification badge - show when there are unread notifications */}
+ {/* 3 */}
+
+
+ Notifications
+
+
+ {/* Settings */}
+
+
+
+
+
+
+ Settings
+
+
+ {/* Settings Dialog */}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/layout/top-bar/top-bar.tsx b/apps/ui/src/components/layout/top-bar/top-bar.tsx
new file mode 100644
index 00000000..54497ddf
--- /dev/null
+++ b/apps/ui/src/components/layout/top-bar/top-bar.tsx
@@ -0,0 +1,157 @@
+import { useState, useCallback } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore, type ThemeMode } from '@/store/app-store';
+import type { Project } from '@/lib/electron';
+import { ProjectSwitcher } from './project-switcher';
+import { PinnedProjects } from './pinned-projects';
+import { TopBarActions } from './top-bar-actions';
+import { OnboardingWizard } from '@/components/dialogs/onboarding-wizard';
+import { getElectronAPI } from '@/lib/electron';
+import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
+import { toast } from 'sonner';
+
+export function TopBar() {
+ const navigate = useNavigate();
+ const {
+ currentProject,
+ projects,
+ pinnedProjectIds,
+ trashedProjects,
+ theme: globalTheme,
+ upsertAndSetCurrentProject,
+ } = useAppStore();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [showOnboarding, setShowOnboarding] = useState(false);
+ const [onboardingMode, setOnboardingMode] = useState<'new' | 'existing'>('new');
+ const [pendingProjectPath, setPendingProjectPath] = useState(undefined);
+
+ const pinnedProjects = projects.filter((p) => pinnedProjectIds.includes(p.id));
+
+ const handleLogoClick = useCallback(() => {
+ navigate({ to: '/dashboard' });
+ }, [navigate]);
+
+ const handleNewProject = useCallback(() => {
+ setPendingProjectPath(undefined);
+ setOnboardingMode('new');
+ setShowOnboarding(true);
+ }, []);
+
+ const handleOpenFolder = useCallback(async () => {
+ const api = getElectronAPI();
+ const result = await api.openDirectory();
+
+ if (!result.canceled && result.filePaths[0]) {
+ const path = result.filePaths[0];
+ const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
+
+ try {
+ const hadAutomakerDir = await hasAutomakerDir(path);
+ const initResult = await initializeProject(path);
+
+ if (!initResult.success) {
+ toast.error('Failed to initialize project', {
+ description: initResult.error || 'Unknown error occurred',
+ });
+ return;
+ }
+
+ const trashedProject = trashedProjects.find((p) => p.path === path);
+ const effectiveTheme =
+ (trashedProject?.theme as ThemeMode | undefined) ||
+ (currentProject?.theme as ThemeMode | undefined) ||
+ globalTheme;
+
+ upsertAndSetCurrentProject(path, name, effectiveTheme);
+
+ const specExists = await hasAppSpec(path);
+
+ if (!hadAutomakerDir || !specExists) {
+ setPendingProjectPath(path);
+ setOnboardingMode(hadAutomakerDir ? 'existing' : 'new');
+ setShowOnboarding(true);
+ } else {
+ navigate({ to: '/board' });
+ toast.success('Project opened', { description: `Opened ${name}` });
+ }
+ } catch (error) {
+ toast.error('Failed to open project', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+ }, [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]);
+
+ return (
+
+ {/* Logo */}
+
+
+
+
+ {/* Pinned Projects */}
+
+
+ {/* Project Dropdown */}
+
+
+ {/* Spacer */}
+
+
+ {/* Actions */}
+
+
+ {/* Onboarding Wizard */}
+
+
+ );
+}
diff --git a/apps/ui/src/components/ui/context-menu.tsx b/apps/ui/src/components/ui/context-menu.tsx
new file mode 100644
index 00000000..daca1e4f
--- /dev/null
+++ b/apps/ui/src/components/ui/context-menu.tsx
@@ -0,0 +1,186 @@
+import * as React from 'react';
+import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
+import { cn } from '@/lib/utils';
+import { Check, ChevronRight, Circle } from 'lucide-react';
+
+const ContextMenu = ContextMenuPrimitive.Root;
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+
+const ContextMenuGroup = ContextMenuPrimitive.Group;
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal;
+
+const ContextMenuSub = ContextMenuPrimitive.Sub;
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
+
+const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+ContextMenuShortcut.displayName = 'ContextMenuShortcut';
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+};
diff --git a/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx b/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx
index d55339a4..98f00ef4 100644
--- a/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx
+++ b/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx
@@ -16,10 +16,24 @@ interface AgentModelSelectorProps {
onChange: (entry: PhaseModelEntry) => void;
/** Disabled state */
disabled?: boolean;
+ /** Custom trigger class name */
+ triggerClassName?: string;
}
-export function AgentModelSelector({ value, onChange, disabled }: AgentModelSelectorProps) {
+export function AgentModelSelector({
+ value,
+ onChange,
+ disabled,
+ triggerClassName,
+}: AgentModelSelectorProps) {
return (
-
+
);
}
diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx
index c0000b85..9a53077c 100644
--- a/apps/ui/src/components/views/board-view.tsx
+++ b/apps/ui/src/components/views/board-view.tsx
@@ -22,9 +22,6 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
-import { BoardHeader } from './board-view/board-header';
-import { BoardSearchBar } from './board-view/board-search-bar';
-import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import {
@@ -172,8 +169,9 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
- // Search filter for Kanban cards
- const [searchQuery, setSearchQuery] = useState('');
+ // Search filter for Kanban cards - using store state for top bar integration
+ const searchQuery = useAppStore((state) => state.boardSearchQuery);
+ const setSearchQuery = useAppStore((state) => state.setBoardSearchQuery);
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
@@ -247,6 +245,26 @@ export function BoardView() {
setIsMounted(true);
}, []);
+ // Listen for custom events from top bar to open dialogs
+ useEffect(() => {
+ const handleOpenAddFeature = () => setShowAddDialog(true);
+ const handleOpenPlanDialog = () => setShowPlanDialog(true);
+ const handleOpenBoardBackground = () => setShowBoardBackgroundModal(true);
+ const handleOpenCompletedFeatures = () => setShowCompletedModal(true);
+
+ window.addEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
+ window.addEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
+ window.addEventListener('automaker:open-board-background', handleOpenBoardBackground);
+ window.addEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
+
+ return () => {
+ window.removeEventListener('automaker:open-add-feature-dialog', handleOpenAddFeature);
+ window.removeEventListener('automaker:open-plan-dialog', handleOpenPlanDialog);
+ window.removeEventListener('automaker:open-board-background', handleOpenBoardBackground);
+ window.removeEventListener('automaker:open-completed-features', handleOpenCompletedFeatures);
+ };
+ }, []);
+
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -1138,30 +1156,6 @@ export function BoardView() {
className="flex-1 flex flex-col overflow-hidden content-bg relative"
data-testid="board-view"
>
- {/* Header */}
- {
- if (enabled) {
- autoMode.start();
- } else {
- autoMode.stop();
- }
- }}
- onAddFeature={() => setShowAddDialog(true)}
- onOpenPlanDialog={() => setShowPlanDialog(true)}
- addFeatureShortcut={{
- key: shortcuts.addFeature,
- action: () => setShowAddDialog(true),
- description: 'Add new feature',
- }}
- isMounted={isMounted}
- />
-
{/* Worktree Panel */}
- {/* Search Bar Row */}
-
-
-
- {/* Board Background & Detail Level Controls */}
- setShowBoardBackgroundModal(true)}
- onShowCompletedModal={() => setShowCompletedModal(true)}
- completedCount={completedFeatures.length}
- kanbanCardDetailLevel={kanbanCardDetailLevel}
- onDetailLevelChange={setKanbanCardDetailLevel}
- boardViewMode={boardViewMode}
- onBoardViewModeChange={setBoardViewMode}
- />
-
{/* View Content - Kanban or Graph */}
{boardViewMode === 'kanban' ? (
setShowAddDialog(true)}
/>
) : (
void;
- isAutoModeRunning: boolean;
- onAutoModeToggle: (enabled: boolean) => void;
onAddFeature: () => void;
onOpenPlanDialog: () => void;
addFeatureShortcut: KeyboardShortcut;
isMounted: boolean;
}
-// Shared styles for header control containers
-const controlContainerClass =
- 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border';
-
export function BoardHeader({
- projectName,
- maxConcurrency,
- runningAgentsCount,
- onConcurrencyChange,
- isAutoModeRunning,
- onAutoModeToggle,
onAddFeature,
onOpenPlanDialog,
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
- const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
- const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
- const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Claude usage tracking visibility logic
@@ -62,90 +38,30 @@ export function BoardHeader({
const showCodexUsage = !!codexAuthStatus?.authenticated;
return (
-
-
-
Kanban Board
-
{projectName}
-
-
- {/* Usage Popover - show if either provider is authenticated */}
- {isMounted && (showClaudeUsage || showCodexUsage) &&
}
+
+ {/* Usage Popover - show if either provider is authenticated */}
+ {isMounted && (showClaudeUsage || showCodexUsage) &&
}
- {/* Concurrency Slider - only show after mount to prevent hydration issues */}
- {isMounted && (
-
-
- Agents
- onConcurrencyChange(value[0])}
- min={1}
- max={10}
- step={1}
- className="w-20"
- data-testid="concurrency-slider"
- />
-
- {runningAgentsCount} / {maxConcurrency}
-
-
- )}
+
+
+ Plan
+
- {/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
- {isMounted && (
-
-
-
- setShowAutoModeSettings(true)}
- className="p-1 rounded hover:bg-accent/50 transition-colors"
- title="Auto Mode Settings"
- data-testid="auto-mode-settings-button"
- >
-
-
-
- )}
-
- {/* Auto Mode Settings Dialog */}
-
-
-
-
- Plan
-
-
-
-
- Add Feature
-
-
+
+
+ Add Feature
+
);
}
diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx
index 2962852d..115f7d9e 100644
--- a/apps/ui/src/components/views/board-view/kanban-board.tsx
+++ b/apps/ui/src/components/views/board-view/kanban-board.tsx
@@ -4,7 +4,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
-import { Archive, Settings2, CheckSquare, GripVertical } from 'lucide-react';
+import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
@@ -50,6 +50,8 @@ interface KanbanBoardProps {
selectedFeatureIds?: Set
;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
+ // Add feature action
+ onAddFeature?: () => void;
}
export function KanbanBoard({
@@ -84,6 +86,7 @@ export function KanbanBoard({
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
+ onAddFeature,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -100,7 +103,7 @@ export function KanbanBoard({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
-
+
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
@@ -127,26 +130,36 @@ export function KanbanBoard({
Complete All
) : column.id === 'backlog' ? (
-
- {isSelectionMode ? (
- <>
-
- Drag
- >
- ) : (
- <>
-
- Select
- >
- )}
-
+
+
+
+ Add
+
+
+ {isSelectionMode ? (
+ <>
+
+ Drag
+ >
+ ) : (
+ <>
+
+ Select
+ >
+ )}
+
+
) : column.id === 'in_progress' ? (
s.worktreePanelCollapsed);
- const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed);
-
- const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
@@ -138,44 +135,14 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
- // Collapsed view - just show current branch and toggle
+ // When collapsed, hide the entire panel
if (isCollapsed) {
- return (
-
-
-
-
-
-
Branch:
-
{selectedWorktree?.branch ?? 'main'}
- {selectedWorktree?.hasChanges && (
-
- {selectedWorktree.changedFilesCount ?? '!'}
-
- )}
-
- );
+ return null;
}
// Expanded view - full worktree panel
return (
-
-
-
-
Branch:
diff --git a/apps/ui/src/components/views/dashboard-view/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view/dashboard-view.tsx
new file mode 100644
index 00000000..ead25c1d
--- /dev/null
+++ b/apps/ui/src/components/views/dashboard-view/dashboard-view.tsx
@@ -0,0 +1,320 @@
+import { useState, useCallback } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import { ProjectCard } from './project-card';
+import { EmptyState } from './empty-state';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Plus, Search, FolderOpen } from 'lucide-react';
+import { getElectronAPI } from '@/lib/electron';
+import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
+import type { ThemeMode } from '@/store/app-store';
+import { toast } from 'sonner';
+import { OnboardingWizard } from '@/components/dialogs/onboarding-wizard';
+import { useOSDetection } from '@/hooks/use-os-detection';
+
+const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
+
+function getOSAbbreviation(os: string): string {
+ switch (os) {
+ case 'mac':
+ return 'M';
+ case 'windows':
+ return 'W';
+ case 'linux':
+ return 'L';
+ default:
+ return '?';
+ }
+}
+
+export function DashboardView() {
+ const navigate = useNavigate();
+ const {
+ projects,
+ trashedProjects,
+ currentProject,
+ upsertAndSetCurrentProject,
+ theme: globalTheme,
+ } = useAppStore();
+ const { os } = useOSDetection();
+ const appMode = import.meta.env.VITE_APP_MODE || '?';
+ const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [showOnboarding, setShowOnboarding] = useState(false);
+ const [onboardingMode, setOnboardingMode] = useState<'new' | 'existing'>('new');
+ const [pendingProjectPath, setPendingProjectPath] = useState
(undefined);
+
+ const filteredProjects = projects.filter((p) =>
+ p.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Sort by last opened (most recent first)
+ const sortedProjects = [...filteredProjects].sort((a, b) => {
+ const aTime = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
+ const bTime = b.lastOpened ? new Date(b.lastOpened).getTime() : 0;
+ return bTime - aTime;
+ });
+
+ const handleOpenFolder = useCallback(async () => {
+ const api = getElectronAPI();
+ const result = await api.openDirectory();
+
+ if (!result.canceled && result.filePaths[0]) {
+ const path = result.filePaths[0];
+ const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
+
+ try {
+ const hadAutomakerDir = await hasAutomakerDir(path);
+ const initResult = await initializeProject(path);
+
+ if (!initResult.success) {
+ toast.error('Failed to initialize project', {
+ description: initResult.error || 'Unknown error occurred',
+ });
+ return;
+ }
+
+ const trashedProject = trashedProjects.find((p) => p.path === path);
+ const effectiveTheme =
+ (trashedProject?.theme as ThemeMode | undefined) ||
+ (currentProject?.theme as ThemeMode | undefined) ||
+ globalTheme;
+
+ upsertAndSetCurrentProject(path, name, effectiveTheme);
+
+ const specExists = await hasAppSpec(path);
+
+ if (!hadAutomakerDir || !specExists) {
+ // Show onboarding for project that needs setup
+ setPendingProjectPath(path);
+ setOnboardingMode(hadAutomakerDir ? 'existing' : 'new');
+ setShowOnboarding(true);
+ } else {
+ navigate({ to: '/board' });
+ toast.success('Project opened', { description: `Opened ${name}` });
+ }
+ } catch (error) {
+ toast.error('Failed to open project', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+ }, [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]);
+
+ const handleNewProject = useCallback(() => {
+ setPendingProjectPath(undefined);
+ setOnboardingMode('new');
+ setShowOnboarding(true);
+ }, []);
+
+ const handleProjectClick = useCallback(
+ (projectId: string) => {
+ const project = projects.find((p) => p.id === projectId);
+ if (project) {
+ upsertAndSetCurrentProject(
+ project.path,
+ project.name,
+ project.theme as ThemeMode | undefined
+ );
+ navigate({ to: '/board' });
+ }
+ },
+ [projects, upsertAndSetCurrentProject, navigate]
+ );
+
+ // Show empty state for new users
+ if (projects.length === 0) {
+ return (
+
+ {/* Branding Header */}
+
+
+
+
+ automaker.
+
+
+ v{appVersion} {versionSuffix}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Branding Header */}
+
+
+
+
+ automaker.
+
+
+ v{appVersion} {versionSuffix}
+
+
+
+
+
+ {/* Header */}
+
+
+
Projects
+
+ {projects.length} project{projects.length !== 1 ? 's' : ''}
+
+
+
+
+
+ Open Folder
+
+
+
+ New Project
+
+
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-10"
+ />
+
+
+ {/* Project Grid */}
+
+ {sortedProjects.map((project) => (
+
handleProjectClick(project.id)}
+ />
+ ))}
+
+
+ {/* No results */}
+ {filteredProjects.length === 0 && searchQuery && (
+
+
No projects matching "{searchQuery}"
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/dashboard-view/empty-state.tsx b/apps/ui/src/components/views/dashboard-view/empty-state.tsx
new file mode 100644
index 00000000..b688086e
--- /dev/null
+++ b/apps/ui/src/components/views/dashboard-view/empty-state.tsx
@@ -0,0 +1,131 @@
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Plus, FolderOpen, Sparkles, Rocket } from 'lucide-react';
+
+interface EmptyStateProps {
+ onNewProject: () => void;
+ onOpenFolder: () => void;
+}
+
+export function EmptyState({ onNewProject, onOpenFolder }: EmptyStateProps) {
+ return (
+
+
+ {/* Welcome Header */}
+
+
+
+
+
Welcome to Automaker
+
+ Your AI-powered development studio. Let's get started.
+
+
+
+ {/* Options */}
+
+ {/* New Project */}
+
+
+
+
+
+
New Project
+
+ Start fresh with a new project. We'll help you set up your app spec and generate
+ initial features.
+
+
+
+
+
+
+ Includes AI-powered feature ideation
+
+
+
+
+
+ {/* Open Existing */}
+
+
+
+
+
+
+
+
Open Existing Project
+
+ Already have a codebase? Open it and let AI help you build new features.
+
+
+
+
+
+
+ Auto-detects your tech stack
+
+
+
+
+
+
+ {/* Getting Started Steps */}
+
+
How it works
+
+
+
+ 1
+
+ Add your project
+
+
+
+
+ 2
+
+ Create features
+
+
+
+
+ 3
+
+ Let AI build
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/dashboard-view/index.ts b/apps/ui/src/components/views/dashboard-view/index.ts
new file mode 100644
index 00000000..34932db3
--- /dev/null
+++ b/apps/ui/src/components/views/dashboard-view/index.ts
@@ -0,0 +1,3 @@
+export { DashboardView } from './dashboard-view';
+export { ProjectCard } from './project-card';
+export { EmptyState } from './empty-state';
diff --git a/apps/ui/src/components/views/dashboard-view/project-card.tsx b/apps/ui/src/components/views/dashboard-view/project-card.tsx
new file mode 100644
index 00000000..7b15d152
--- /dev/null
+++ b/apps/ui/src/components/views/dashboard-view/project-card.tsx
@@ -0,0 +1,129 @@
+import { useCallback } from 'react';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import type { Project } from '@/lib/electron';
+import { Card, CardContent } from '@/components/ui/card';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu';
+import { Folder, Star, Settings, Trash2, MoreVertical } from 'lucide-react';
+import { formatDistanceToNow } from 'date-fns';
+
+interface ProjectCardProps {
+ project: Project;
+ onClick: () => void;
+}
+
+export function ProjectCard({ project, onClick }: ProjectCardProps) {
+ const { pinnedProjectIds, pinProject, unpinProject, moveProjectToTrash, getAutoModeState } =
+ useAppStore();
+
+ const isPinned = pinnedProjectIds.includes(project.id);
+ const autoModeState = getAutoModeState(project.id);
+ const runningCount = autoModeState?.runningTasks?.length ?? 0;
+
+ const handleTogglePin = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isPinned) {
+ unpinProject(project.id);
+ } else {
+ pinProject(project.id);
+ }
+ },
+ [isPinned, project.id, pinProject, unpinProject]
+ );
+
+ const handleRemove = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ moveProjectToTrash(project.id);
+ },
+ [project.id, moveProjectToTrash]
+ );
+
+ const lastOpened = project.lastOpened
+ ? formatDistanceToNow(new Date(project.lastOpened), { addSuffix: true })
+ : 'Never opened';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
{project.name}
+ {isPinned && (
+
+ )}
+ {runningCount > 0 && (
+
+
+ {runningCount}
+
+ )}
+
+
{project.path}
+
+
+
+
+
+
+
+
+
+
+
+ Open Project
+
+
+
+ {isPinned ? 'Unpin from bar' : 'Pin to bar'}
+
+
+
+
+ Remove Project
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx
index 15ade5cc..8648ac93 100644
--- a/apps/ui/src/components/views/settings-view.tsx
+++ b/apps/ui/src/components/views/settings-view.tsx
@@ -1,227 +1,14 @@
-import { useState } from 'react';
-import { useAppStore } from '@/store/app-store';
-import { useSetupStore } from '@/store/setup-store';
-
-import { useSettingsView, type SettingsViewId } from './settings-view/hooks';
-import { NAV_ITEMS } from './settings-view/config/navigation';
import { SettingsHeader } from './settings-view/components/settings-header';
-import { KeyboardMapDialog } from './settings-view/components/keyboard-map-dialog';
-import { DeleteProjectDialog } from './settings-view/components/delete-project-dialog';
-import { SettingsNavigation } from './settings-view/components/settings-navigation';
-import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
-import { ModelDefaultsSection } from './settings-view/model-defaults';
-import { AppearanceSection } from './settings-view/appearance/appearance-section';
-import { TerminalSection } from './settings-view/terminal/terminal-section';
-import { AudioSection } from './settings-view/audio/audio-section';
-import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
-import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
-import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
-import { AccountSection } from './settings-view/account';
-import { SecuritySection } from './settings-view/security';
-import {
- ClaudeSettingsTab,
- CursorSettingsTab,
- CodexSettingsTab,
- OpencodeSettingsTab,
-} from './settings-view/providers';
-import { MCPServersSection } from './settings-view/mcp-servers';
-import { PromptCustomizationSection } from './settings-view/prompts';
-import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
-import type { Project as ElectronProject } from '@/lib/electron';
+import { SettingsContent } from './settings-view/settings-content';
export function SettingsView() {
- const {
- theme,
- setTheme,
- setProjectTheme,
- defaultSkipTests,
- setDefaultSkipTests,
- enableDependencyBlocking,
- setEnableDependencyBlocking,
- skipVerificationInAutoMode,
- setSkipVerificationInAutoMode,
- useWorktrees,
- setUseWorktrees,
- showProfilesOnly,
- setShowProfilesOnly,
- muteDoneSound,
- setMuteDoneSound,
- currentProject,
- moveProjectToTrash,
- defaultPlanningMode,
- setDefaultPlanningMode,
- defaultRequirePlanApproval,
- setDefaultRequirePlanApproval,
- defaultAIProfileId,
- setDefaultAIProfileId,
- aiProfiles,
- autoLoadClaudeMd,
- setAutoLoadClaudeMd,
- promptCustomization,
- setPromptCustomization,
- skipSandboxWarning,
- setSkipSandboxWarning,
- } = useAppStore();
-
- // Convert electron Project to settings-view Project type
- const convertProject = (project: ElectronProject | null): SettingsProject | null => {
- if (!project) return null;
- return {
- id: project.id,
- name: project.name,
- path: project.path,
- theme: project.theme as Theme | undefined,
- };
- };
-
- const settingsProject = convertProject(currentProject);
-
- // Compute the effective theme for the current project
- const effectiveTheme = (settingsProject?.theme || theme) as Theme;
-
- // Handler to set theme - always updates global theme (user's preference),
- // and also sets per-project theme if a project is selected
- const handleSetTheme = (newTheme: typeof theme) => {
- // Always update global theme so user's preference persists across all projects
- setTheme(newTheme);
- // Also set per-project theme if a project is selected
- if (currentProject) {
- setProjectTheme(currentProject.id, newTheme);
- }
- };
-
- // Use settings view navigation hook
- const { activeView, navigateTo } = useSettingsView();
-
- // Handle navigation - if navigating to 'providers', default to 'claude-provider'
- const handleNavigate = (viewId: SettingsViewId) => {
- if (viewId === 'providers') {
- navigateTo('claude-provider');
- } else {
- navigateTo(viewId);
- }
- };
-
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
-
- // Render the active section based on current view
- const renderActiveSection = () => {
- switch (activeView) {
- case 'claude-provider':
- return ;
- case 'cursor-provider':
- return ;
- case 'codex-provider':
- return ;
- case 'opencode-provider':
- return ;
- case 'providers':
- case 'claude': // Backwards compatibility - redirect to claude-provider
- return ;
- case 'mcp-servers':
- return ;
- case 'prompts':
- return (
-
- );
- case 'model-defaults':
- return ;
- case 'appearance':
- return (
- handleSetTheme(theme as any)}
- />
- );
- case 'terminal':
- return ;
- case 'keyboard':
- return (
- setShowKeyboardMapDialog(true)} />
- );
- case 'audio':
- return (
-
- );
- case 'defaults':
- return (
-
- );
- case 'account':
- return ;
- case 'security':
- return (
-
- );
- case 'danger':
- return (
- setShowDeleteDialog(true)}
- />
- );
- default:
- return ;
- }
- };
-
return (
{/* Header Section */}
{/* Content Area with Sidebar */}
-
- {/* Side Navigation - No longer scrolls, just switches views */}
-
-
- {/* Content Panel - Shows only the active section */}
-
-
{renderActiveSection()}
-
-
-
- {/* Keyboard Map Dialog */}
-
-
- {/* Delete Project Confirmation Dialog */}
-
+
);
}
diff --git a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx
index 4af7f5bf..69950da4 100644
--- a/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx
+++ b/apps/ui/src/components/views/settings-view/components/settings-navigation.tsx
@@ -13,6 +13,7 @@ interface SettingsNavigationProps {
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: SettingsViewId) => void;
+ compact?: boolean;
}
function NavButton({
@@ -167,11 +168,13 @@ export function SettingsNavigation({
activeSection,
currentProject,
onNavigate,
+ compact = false,
}: SettingsNavigationProps) {
return (