An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat(web): add app shell with sidebar navigation and routing

Implements Task 5 of Phase 3 (Stores + Router + App Shell):
- Replace App.svelte placeholder with full app shell component
- Add sidebar navigation with project selector and main nav links
- Implement view routing with Placeholder components for all routes
- Initialize WebSocket connection and dispatch events to graph/agents stores
- Use flexbox layout with fixed sidebar (240px) and scrollable main content
- Maintain dark theme with colors #12122a (sidebar) and #1a1a2e (main)

Also fixes Svelte 5 module-level $derived export issue:
- Convert selectedProject to getSelectedProject() function
- Convert goalNodes to getGoalNodes() function
- Convert tasksByStatus to getTasksByStatus() function

All files pass TypeScript check and build succeeds with no errors.
All router tests pass (14/14) and WebSocket tests pass (14/14).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+293 -14
+87 -5
web/src/App.svelte
··· 1 1 <script lang="ts"> 2 - let message = $state('Rustagent Web UI'); 2 + /** 3 + * Main app shell component. 4 + * Provides layout with sidebar navigation and routed main content area. 5 + * Manages WebSocket connection and dispatches events to stores. 6 + */ 7 + 8 + import { getCurrentRoute, navigate, routerState } from './router.svelte'; 9 + import { createWsConnection } from './api/websocket'; 10 + import { handleWsEvent as handleGraphWsEvent } from './stores/graph.svelte'; 11 + import { handleWsEvent as handleAgentsWsEvent } from './stores/agents.svelte'; 12 + import Sidebar from './components/Sidebar.svelte'; 13 + import Placeholder from './views/Placeholder.svelte'; 14 + 15 + let wsConnection: ReturnType<typeof createWsConnection> | null = null; 16 + 17 + // Initialize WebSocket connection on mount 18 + $effect(() => { 19 + if (!wsConnection) { 20 + wsConnection = createWsConnection(); 21 + wsConnection.connect(); 22 + 23 + // Register event handler that dispatches to all stores 24 + const handleEvent = (event: any) => { 25 + handleGraphWsEvent(event); 26 + handleAgentsWsEvent(event); 27 + }; 28 + 29 + wsConnection.onEvent(handleEvent); 30 + 31 + // Cleanup on unmount 32 + return () => { 33 + if (wsConnection) { 34 + wsConnection.disconnect(); 35 + wsConnection = null; 36 + } 37 + }; 38 + } 39 + }); 40 + 41 + // Get current route 42 + const currentRoute = $derived(getCurrentRoute()); 3 43 </script> 4 44 5 - <main> 6 - <h1>{message}</h1> 7 - <p>Web UI is running.</p> 8 - </main> 45 + <div class="app-container"> 46 + <Sidebar /> 47 + 48 + <main class="main-content"> 49 + {#if currentRoute.name === 'dashboard'} 50 + <Placeholder name="Dashboard" /> 51 + {:else if currentRoute.name === 'project-list'} 52 + <Placeholder name="Project List" /> 53 + {:else if currentRoute.name === 'project-detail'} 54 + <Placeholder name="Project Detail" /> 55 + {:else if currentRoute.name === 'task-tree'} 56 + <Placeholder name="Task Tree" /> 57 + {:else if currentRoute.name === 'decision-graph'} 58 + <Placeholder name="Decision Graph" /> 59 + {:else if currentRoute.name === 'agent-monitor'} 60 + <Placeholder name="Agent Monitor" /> 61 + {:else if currentRoute.name === 'session-history'} 62 + <Placeholder name="Session History" /> 63 + {:else if currentRoute.name === 'graph-search'} 64 + <Placeholder name="Graph Search" /> 65 + {:else} 66 + <Placeholder name="Not Found" /> 67 + {/if} 68 + </main> 69 + </div> 70 + 71 + <style> 72 + :global(body) { 73 + margin: 0; 74 + padding: 0; 75 + overflow: hidden; 76 + } 77 + 78 + .app-container { 79 + display: flex; 80 + width: 100vw; 81 + height: 100vh; 82 + } 83 + 84 + .main-content { 85 + flex: 1; 86 + background: #1a1a2e; 87 + overflow-y: auto; 88 + padding: 0; 89 + } 90 + </style>
+163
web/src/components/Sidebar.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Sidebar navigation component. 4 + * Provides links to main views and a project selector. 5 + */ 6 + 7 + import { navigate, routerState, getCurrentRoute } from '../router.svelte'; 8 + import { projectsState, getSelectedProject, loadProjects } from '../stores/projects.svelte'; 9 + 10 + // Determine if a link is active based on current route 11 + function isActive(route: string): boolean { 12 + const currentRoute = getCurrentRoute(); 13 + return routerState.path === route; 14 + } 15 + 16 + // Load projects on mount 17 + $effect(() => { 18 + if (projectsState.projects.length === 0) { 19 + loadProjects(); 20 + } 21 + }); 22 + </script> 23 + 24 + <aside class="sidebar"> 25 + <nav class="nav"> 26 + <div class="nav-section"> 27 + <a 28 + href="/#/" 29 + class="nav-link" 30 + class:active={isActive('/')} 31 + onclick={(e) => { 32 + e.preventDefault(); 33 + navigate('/'); 34 + }} 35 + > 36 + Dashboard 37 + </a> 38 + <a 39 + href="/#/projects" 40 + class="nav-link" 41 + class:active={isActive('/projects')} 42 + onclick={(e) => { 43 + e.preventDefault(); 44 + navigate('/projects'); 45 + }} 46 + > 47 + Projects 48 + </a> 49 + <a 50 + href="/#/search" 51 + class="nav-link" 52 + class:active={isActive('/search')} 53 + onclick={(e) => { 54 + e.preventDefault(); 55 + navigate('/search'); 56 + }} 57 + > 58 + Search 59 + </a> 60 + </div> 61 + 62 + {#if projectsState.projects.length > 0} 63 + <div class="nav-section"> 64 + <div class="section-title">Projects</div> 65 + <div class="project-list"> 66 + {#each projectsState.projects as project (project.id)} 67 + {@const selected = getSelectedProject()} 68 + <button 69 + class="project-item" 70 + class:active={selected?.id === project.id} 71 + onclick={() => navigate(`/projects/${project.id}`)} 72 + > 73 + {project.name} 74 + </button> 75 + {/each} 76 + </div> 77 + </div> 78 + {/if} 79 + </nav> 80 + </aside> 81 + 82 + <style> 83 + .sidebar { 84 + width: 240px; 85 + background: #12122a; 86 + border-right: 1px solid #2a2a4a; 87 + height: 100%; 88 + overflow-y: auto; 89 + flex-shrink: 0; 90 + } 91 + 92 + .nav { 93 + padding: 1rem 0; 94 + display: flex; 95 + flex-direction: column; 96 + gap: 1rem; 97 + } 98 + 99 + .nav-section { 100 + padding: 0.5rem 0; 101 + } 102 + 103 + .nav-link { 104 + display: block; 105 + padding: 0.75rem 1.5rem; 106 + color: #a0a0b0; 107 + text-decoration: none; 108 + transition: all 0.2s ease; 109 + border-left: 3px solid transparent; 110 + } 111 + 112 + .nav-link:hover { 113 + background: #1a1a3a; 114 + color: #e0e0e0; 115 + border-left-color: #6a5acd; 116 + } 117 + 118 + .nav-link.active { 119 + background: #1a1a3a; 120 + color: #e0e0e0; 121 + border-left-color: #6a5acd; 122 + } 123 + 124 + .section-title { 125 + padding: 0.5rem 1.5rem; 126 + font-size: 0.75rem; 127 + font-weight: 600; 128 + text-transform: uppercase; 129 + color: #6a6a7a; 130 + letter-spacing: 0.5px; 131 + } 132 + 133 + .project-list { 134 + display: flex; 135 + flex-direction: column; 136 + gap: 0.25rem; 137 + padding: 0 0.5rem; 138 + } 139 + 140 + .project-item { 141 + padding: 0.5rem 1rem; 142 + margin: 0 0.5rem; 143 + background: transparent; 144 + border: none; 145 + color: #a0a0b0; 146 + text-align: left; 147 + cursor: pointer; 148 + transition: all 0.2s ease; 149 + border-radius: 0.25rem; 150 + font-size: 0.875rem; 151 + } 152 + 153 + .project-item:hover { 154 + background: #1a1a3a; 155 + color: #e0e0e0; 156 + } 157 + 158 + .project-item.active { 159 + background: #2a2a4a; 160 + color: #e0e0e0; 161 + font-weight: 600; 162 + } 163 + </style>
+6 -6
web/src/stores/graph.svelte.ts
··· 42 42 }); 43 43 44 44 /** 45 - * Derived: goal nodes from the current goal tree. 45 + * Get goal nodes from the current goal tree. 46 46 */ 47 - export const goalNodes = $derived.by(() => { 47 + export function getGoalNodes(): Array<GraphNode> { 48 48 if (!graphState.goalTree) { 49 49 return []; 50 50 } 51 51 return graphState.goalTree.nodes.filter((n) => n.node_type === 'goal'); 52 - }); 52 + } 53 53 54 54 /** 55 - * Derived: tasks grouped by status. 55 + * Get tasks grouped by status. 56 56 */ 57 - export const tasksByStatus = $derived.by(() => { 57 + export function getTasksByStatus(): Record<NodeStatus, Array<GraphNode>> { 58 58 const result: Record<NodeStatus, Array<GraphNode>> = {} as Record< 59 59 NodeStatus, 60 60 Array<GraphNode> ··· 68 68 } 69 69 70 70 return result; 71 - }); 71 + } 72 72 73 73 const apiClient = createApiClient(); 74 74
+3 -3
web/src/stores/projects.svelte.ts
··· 23 23 }); 24 24 25 25 /** 26 - * Derived: the currently selected project, or null if none selected. 26 + * Get the currently selected project, or null if none selected. 27 27 */ 28 - export const selectedProject = $derived.by(() => { 28 + export function getSelectedProject(): ProjectResponse | null { 29 29 if (!projectsState.selectedProjectId) { 30 30 return null; 31 31 } 32 32 return ( 33 33 projectsState.projects.find((p) => p.id === projectsState.selectedProjectId) || null 34 34 ); 35 - }); 35 + } 36 36 37 37 const apiClient = createApiClient(); 38 38
+34
web/src/views/Placeholder.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * Placeholder component for views under development. 4 + * Renders a simple message indicating the view is coming soon. 5 + */ 6 + 7 + interface Props { 8 + name: string; 9 + } 10 + 11 + let { name }: Props = $props(); 12 + </script> 13 + 14 + <div class="placeholder"> 15 + <h2>{name}</h2> 16 + <p>Coming soon...</p> 17 + </div> 18 + 19 + <style> 20 + .placeholder { 21 + padding: 2rem; 22 + text-align: center; 23 + color: #999; 24 + } 25 + 26 + h2 { 27 + margin: 0 0 1rem 0; 28 + color: #e0e0e0; 29 + } 30 + 31 + p { 32 + margin: 0; 33 + } 34 + </style>