24 KiB
Phase 3C: UI & Frontend Code Quality Audit
Auditor: Senior Engineer Code Review (automated)
Date: 2026-03-20
Scope: app/src/ (Tauri desktop app) + packages/ui/src/ (shared React component library)
Mode: READ-ONLY
Summary Counts
| Metric | Count |
|---|---|
Total any type usages |
20 (11 in app/src, 9 in packages/ui/src) |
| Total inline styles | 29 (13 in app/src, 16 in packages/ui/src) |
| Error boundaries | 0 |
| Effects without cleanup (where cleanup is needed) | 5 |
React.memo usage |
0 |
React.lazy / code splitting |
0 |
| ESLint rule suppressions | 5 |
| Dead/orphaned files | 5 |
| Duplicate SSE connections | 2 (same endpoint) |
Findings
CQ-001: Zero Error Boundaries
- Severity: CRITICAL
- Package: app + @waggle/ui
- File: app/src/App.tsx (entire tree)
- Issue: No
ErrorBoundaryorcomponentDidCatchexists anywhere in the app or UI library. The entire component tree is unwrapped. A single uncaught render error in any view (Chat, Cockpit, Memory, Capabilities, MissionControl, Settings) will crash the entire application to a white screen. - Impact: In production, any rendering error (bad API response shape, undefined property access in JSX) will take down the whole desktop app. Users lose all context with no recovery path.
- Fix: Add error boundaries at the view level (wrapping each
<ChatView>,<CockpitView>, etc.) and at the root level inApp(). Each boundary should render a fallback UI with a "Retry" button.
CQ-002: Duplicate SSE Connections to Same Endpoint
- Severity: HIGH
- Package: @waggle/ui
- File: packages/ui/src/hooks/useNotifications.ts:46, packages/ui/src/hooks/useSubAgentStatus.ts:55
- Issue: Both
useNotificationsanduseSubAgentStatusopen independentEventSourceconnections to the exact same endpoint (/api/notifications/stream). This doubles the number of persistent HTTP connections per client. - Impact: Wastes server resources (2x SSE connections per client). Under load or on constrained networks, this halves available connections. Browser SSE connection limits (6 per domain in some browsers) are consumed faster.
- Fix: Create a single shared SSE connection hook (e.g.,
useSSEStream) that multiplexes events to multiple subscribers. BothuseNotificationsanduseSubAgentStatusshould subscribe to the shared connection.
CQ-003: No Code Splitting or Lazy Loading
- Severity: HIGH
- Package: app
- File: app/src/App.tsx, app/vite.config.ts
- Issue: All 7 views (Chat, Memory, Events, Capabilities, Cockpit, MissionControl, Settings) are eagerly imported. No
React.lazy()orSuspenseis used anywhere. The Vite config has nomanualChunksconfiguration inrollupOptions. TheCapabilitiesViewalone is 1227 lines.CockpitViewimports 10 sub-components eagerly. - Impact: The initial bundle includes all views and their dependencies, increasing initial load time. Views like MissionControl and Capabilities that users may rarely visit are loaded upfront.
- Fix: Wrap non-default views with
React.lazy()+Suspense. AddmanualChunkstovite.config.tsto split vendor code (marked, DOMPurify) from app code. At minimum, lazy-load Capabilities, MissionControl, Cockpit, and Settings views.
CQ-004: Monolithic App Component (~1300 lines, 30+ useState calls)
- Severity: HIGH
- Package: app
- File: app/src/App.tsx:87-1290
- Issue:
WaggleAppis a single component with ~30useStatehooks, ~15useEffecthooks, and ~20useCallbackhooks. It manages team messages, notifications, toasts, agent status, offline status, personas, workspace context, file drops, approval gates, slash commands, keyboard shortcuts, tab management, sessions, memory, events, and more -- all in one function body. - Impact: Any state change triggers reconciliation of the entire component. Difficult to test individual concerns. High cognitive load for maintenance. Makes it impossible to optimize re-renders without major refactoring.
- Fix: Extract logical domains into custom hooks or sub-providers:
useTeamState(),useAgentStatus(),useOfflineStatus(),useSlashCommands(),useFileHandling(). Consider a state management solution (Zustand is lightweight and fits) to share state without prop drilling through the 1300-line component.
CQ-005: Zero React.memo Usage Across Entire Codebase
- Severity: MEDIUM
- Package: app + @waggle/ui
- File: all components
- Issue: Not a single component uses
React.memo(). Given the monolithicWaggleAppcomponent, every state change (e.g., toast notification, agent token count update from polling) causes React to re-render and diff the entire component tree including ChatView, ContextPanel, AppSidebar, StatusBar, and all their children. - Impact: Unnecessary re-renders on every 15s offline poll, 30s agent status poll, 30s team message poll, and every SSE notification. On complex views (Capabilities with 100+ packages, Memory with frames, Chat with long message lists), this causes visible jank.
- Fix: Apply
React.memo()to leaf components that receive stable props:ToolCard,ChatMessage,SessionCard,AgentFleetCard,ToastItem,StatusBar. TheChatViewandContextPanelare also good candidates since they receive many callbacks wrapped inuseCallback.
CQ-006: Unsafe as any Casts for Team Adapter Methods
- Severity: HIGH
- Package: app
- File: app/src/App.tsx:910, 916, 921, 926
- Issue: Team-related adapter methods are called via
(adapter as any).getTeamStatus(),(adapter as any).connectTeam(),(adapter as any).disconnectTeam(), and(adapter as any).listTeams(). These methods are not on theWaggleServicetype interface but are called through ananycast with no runtime type checking. - Impact: If the adapter implementation changes or these methods are removed, TypeScript won't catch it. Runtime errors will be silently swallowed (caught by empty catches). The team connection feature could silently break without any indication.
- Fix: Add
getTeamStatus,connectTeam,disconnectTeam, andlistTeamsto theWaggleServiceinterface (or aTeamServiceextension interface) so TypeScript can verify the contract. If these are optional features, use a type guard or feature-detection pattern instead ofas any.
CQ-007: Dead Code — Orphaned Legacy Components
- Severity: MEDIUM
- Package: app
- File: app/src/components/chat/ChatView.tsx, app/src/hooks/useChat.ts, app/src/hooks/useSidecar.ts, app/src/components/layout/Sidebar.tsx, app/src/components/layout/TitleBar.tsx, app/src/components/onboarding/OnboardingWizard.tsx
- Issue: Multiple files are never imported by the active application:
app/src/components/chat/ChatView.tsx— legacy chat view (replaced byapp/src/views/ChatView.tsxwhich uses@waggle/ui)app/src/hooks/useChat.ts— legacy chat hook using oldipc.sendMessage()(only imported by the orphaned ChatView above)app/src/hooks/useSidecar.ts— legacy service connection hook (replaced byServiceProvider)app/src/components/layout/Sidebar.tsx— legacy sidebar (replaced byAppSidebar)app/src/components/layout/TitleBar.tsx— legacy title barapp/src/components/onboarding/OnboardingWizard.tsx— legacy onboarding (replaced by@waggle/ui'sOnboardingWizard)app/src/components/settings/SettingsPanel.tsx— legacy settings (M1-era, uses oldipcAPI; the activeSettingsViewimports from@waggle/ui)
- Impact: Increases bundle size, confuses developers about which components are canonical, and the dead
ipc-based code references API patterns that no longer exist. The legacySettingsPanelstores API keys in plaintext via/api/settingsinstead of the vault. - Fix: Delete the 7 orphaned files. They are M1/M2-era relics superseded by the
@waggle/uicomponent library.
CQ-008: useTeamActivity Fetcher Not Stable — Causes Re-renders
- Severity: MEDIUM
- Package: @waggle/ui
- File: packages/ui/src/hooks/useTeamActivity.ts:31-49
- Issue:
fetchActivityis defined as a plainasync functioninside the component body (not wrapped inuseCallback). It is then called inside auseEffectthat lists[baseUrl, teamId, limit]as dependencies, but the function itself is recreated on every render. The effect works because it captures the function by closure, but thefetchActivityfunction returned asrefreshfrom the hook will be a new reference on every render, causing re-renders in any consumer that uses it in a dependency array. - Impact: Any component using the
refreshcallback in a dependency array or passing it as a prop will re-render on every cycle. Minor performance issue but indicates a pattern inconsistency. - Fix: Wrap
fetchActivityinuseCallbackwith[baseUrl, teamId, limit]as dependencies, matching the pattern used inuseTeamPresence.
CQ-009: Missing Race Condition Guard in useChat History Load
- Severity: MEDIUM
- Package: @waggle/ui
- File: packages/ui/src/hooks/useChat.ts:151-178
- Issue: When session/workspace changes, the effect loads history via
service.getHistory(). WhileabortRef.current = trueis set at the top of the effect to abort in-flight streams, the history load itself is not guarded by a cancellation flag. If a user rapidly switches sessions, completed history loads from a prior session could overwrite messages for the current session. - Impact: When rapidly switching between sessions, messages from the wrong session could briefly appear, creating confusion. The
setMessagescall at line 162 could apply stale data. - Fix: Add a
cancelledflag (likeuseMemorydoes) and check it before callingsetMessagesin the.then()callback. Return a cleanup function that setscancelled = true.
CQ-010: ESLint Exhaustive-Deps Suppressions Hiding Bugs
- Severity: MEDIUM
- Package: app + @waggle/ui
- File: app/src/App.tsx:402, app/src/App.tsx:774, packages/ui/src/hooks/useWorkspaces.ts:60, packages/ui/src/hooks/useSessions.ts:76
- Issue: Five
eslint-disable-line react-hooks/exhaustive-depscomments suppress dependency warnings. Notable cases:- App.tsx:402 —
checkPendingeffect has empty deps[]but referencessetMessagesandSERVER_BASE. WhilesetMessagesis stable (from useState), the pattern hides the dependency onSERVER_BASE. - App.tsx:774 — Keyboard handler effect is missing
handleNewTabfrom dependencies. IfhandleNewTabchanges (e.g., new workspace selected), the keyboard shortcut will use stale data. - useWorkspaces.ts:60 — Missing
activeIddependency. Intentional (to avoid re-fetching when active changes) but the lint suppression hides the rationale.
- App.tsx:402 —
- Impact: Stale closures in keyboard handlers and startup effects. The keyboard shortcut handler (Cmd+T for new tab) may operate on a stale workspace reference.
- Fix: For App.tsx:774, add
handleNewTabto the dependency array. For App.tsx:402, the empty deps are intentional (run once on mount) but should use a ref forSERVER_BASEor document the intentionality with a comment. For useWorkspaces.ts:60, document whyactiveIdis excluded.
CQ-011: setTimeout Without Cleanup in SettingsPanel and ChatMessage
- Severity: LOW
- Package: app + @waggle/ui
- File: app/src/components/settings/SettingsPanel.tsx:25, packages/ui/src/components/chat/ChatMessage.tsx:136
- Issue: Both files use
setTimeoutoutside ofuseEffect, meaning there is no cleanup mechanism:SettingsPanel.tsx:25:setTimeout(() => setSaved(false), 2000)— called in an event handler, not in an effect. If the component unmounts within 2 seconds (user navigates away), React will warn about updating state on an unmounted component.ChatMessage.tsx:136:setTimeout(() => setCopied(false), 1500)— same pattern in a click handler.
- Impact: React "Can't perform a React state update on an unmounted component" warnings in the console. Not a memory leak per se, but indicates sloppy lifecycle management. In production with React strict mode, this produces visible console noise.
- Fix: Use a ref to track mounted state, or use
useEffectwith cleanup for timer-based state resets. Alternatively, use a customuseTimeouthook that auto-cleans up.
CQ-012: Tauri Event Listener Cleanup Race Condition
- Severity: MEDIUM
- Package: app
- File: app/src/App.tsx:214-265
- Issue: The Tauri event listeners are registered asynchronously inside an IIFE within
useEffect. The cleanup functionlisteners.forEach(unlisten => unlisten())runs synchronously when the component unmounts. However, if the component unmounts before theawait listen(...)calls complete, the listeners will be pushed to thelistenersarray after cleanup has already run, leaving dangling event listeners. - Impact: If the component unmounts and remounts quickly (e.g., during hot reload or React strict mode double-render), Tauri event listeners may accumulate. The
waggle://quitlistener could fire multiple times. In production with stable mounts this is unlikely, but it is architecturally unsound. - Fix: Add a
cancelledflag. Checkcancelledbefore pushing tolisteners. In the cleanup, both setcancelled = trueand iterate existing listeners. Alternatively, use an AbortController pattern.
CQ-013: useActiveWorkspace Hook Exported But Never Used
- Severity: LOW
- Package: @waggle/ui
- File: packages/ui/src/hooks/useActiveWorkspace.ts
- Issue: This hook is exported from
packages/ui/src/index.tsbut never imported by any consumer. Theapp/src/App.tsxmanages active workspace state directly viauseWorkspaceswhich returnsactiveWorkspaceandsetActiveWorkspace. - Impact: Dead code in the published package. Increases bundle size marginally and adds confusion about which hook to use for workspace selection.
- Fix: Either remove the hook and its export, or refactor
App.tsxto use it (consolidating workspace selection logic).
CQ-014: SessionList Debounce Timer Not Cleaned Up on Unmount
- Severity: LOW
- Package: @waggle/ui
- File: packages/ui/src/components/sessions/SessionList.tsx:42-53
- Issue: The
debounceRefstores a setTimeout reference for search debouncing. While individual timeouts are cleared when new input arrives (line 47), there is nouseEffectcleanup to clear the pending timeout if the component unmounts while a search is pending. - Impact: If the user types a search query and immediately switches views (unmounting SessionList), the debounced
onSearchcallback will fire after unmount, potentially causing a state update on an unmounted component. - Fix: Add a
useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, [])cleanup.
CQ-015: Inline Styles for Dynamic CSS Variables
- Severity: LOW
- Package: app + @waggle/ui
- File: app/src/App.tsx:1106, app/src/providers/ServiceProvider.tsx:60-107, packages/ui/src/components/chat/ToolCard.tsx:270, packages/ui/src/components/ToastContainer.tsx:60-62
- Issue: 29 inline
style={}usages across the codebase. Most are inServiceProvider.tsx(loading/error screens use pure inline styles instead of Tailwind classes). TheToolCarduses inline styles for transition animations.ToastContaineruses inline styles for dynamic border colors. - Impact: Inline styles bypass the Tailwind design system, create inconsistent styling patterns, and cannot be easily themed. The ServiceProvider loading/error screens look visually disconnected from the rest of the app.
- Fix: Replace inline styles with Tailwind classes where possible. For dynamic values (workspace hue, toast border color), use CSS custom properties set via
stylecombined with Tailwind classes that reference them. The ServiceProvider loading/error screens should use the same Tailwind classes as the rest of the app.
CQ-016: err: any Catches Instead of unknown
- Severity: LOW
- Package: app + @waggle/ui
- File: app/src/providers/ServiceProvider.tsx:46, packages/ui/src/components/settings/TeamSection.tsx:42,55, packages/ui/src/components/onboarding/steps/ReadyStep.tsx:102,136
- Issue: Five catch clauses use
catch (err: any)instead ofcatch (err: unknown)with proper type narrowing. This bypasses TypeScript's strict checking within the catch block. - Impact: Minor type safety gap. Any property access on
erris unchecked, so accessingerr.messageon a non-Error throw would silently produceundefinedinstead of failing at compile time. - Fix: Change to
catch (err: unknown)and useerr instanceof Error ? err.message : String(err)pattern (already used elsewhere in the codebase).
CQ-017: Module-Level Singleton State Outside React
- Severity: LOW
- Package: app + @waggle/ui
- File: app/src/App.tsx:61-62, packages/ui/src/hooks/useSubAgentStatus.ts:37, packages/ui/src/hooks/useChat.ts:29
- Issue: Three module-level singletons exist:
adapter(App.tsx:62) — singleLocalAdapterinstance created at module load timedismissedPatterns(useSubAgentStatus.ts:37) —Set<string>shared across all hook instancesmessageIdCounter(useChat.ts:29) — global counter for message IDs
- Impact: In testing or SSR contexts, these singletons persist across renders/tests. The
dismissedPatternsset grows unboundedly throughout the app's lifetime (minor memory concern). Theadapterbeing module-level means it cannot be reconfigured without a page reload. - Fix: For
adapter, this is acceptable for a desktop app (single instance). FordismissedPatterns, consider a WeakMap or periodic cleanup. FormessageIdCounter, the pattern is safe but could usecrypto.randomUUID()for test isolation.
CQ-018: CapabilitiesView is 1227 Lines — Needs Decomposition
- Severity: MEDIUM
- Package: app
- File: app/src/views/CapabilitiesView.tsx (1227 lines)
- Issue: This single component contains the Packs tab, Marketplace tab (with search/filter/sort), Individual Skills tab (with create-skill form), all the fetching logic, install/uninstall handlers, bulk install progress tracking, and community pack management. It has 20+
useStatecalls, 10+useCallbackhooks, and embeds the entire Create Skill form inline. - Impact: Difficult to test individual features. Very high cognitive load. Any change to marketplace search risks breaking pack install logic. The component cannot be code-split below the view level.
- Fix: Extract into sub-components:
PacksTab,MarketplaceTab,SkillsTab,CreateSkillForm. Extract data-fetching into custom hooks:useCapabilityPacks(),useMarketplace(),useCommunityPacks().
CQ-019: Notifications Converted to Toasts Without Deduplication
- Severity: LOW
- Package: app
- File: app/src/App.tsx:195-208
- Issue: The effect that converts notifications to toasts uses
notifications.length === 0as a guard and always takesnotifications[0](the latest). However, thenotificationsarray fromuseNotificationsaccumulates up to 50 items. If thenotificationsstate reference changes (even without new items), the effect could fire again and create a duplicate toast from the same notification. - Impact: Potential duplicate toasts if the notifications array reference changes without content changes. The
Math.random()in the toast ID prevents deduplication. - Fix: Track the last-processed notification timestamp or ID in a ref. Only create a toast if the latest notification is newer than the last-processed one.
CQ-020: fetchActivity in useTeamActivity Causes Re-render Loop Risk
- Severity: LOW
- Package: @waggle/ui
- File: packages/ui/src/hooks/useTeamActivity.ts:51-53
- Issue: The
useEffectat line 51 has[baseUrl, teamId, limit]in its dependency array, but it callsfetchActivity()which is a plain function (not memoized). ESLint would flagfetchActivityas a missing dependency, but the lint rule isn't running. The function works correctly because it capturesbaseUrl,teamId,limitfrom closure, but the pattern is fragile and inconsistent with other hooks that useuseCallbackfor fetch functions. - Impact: Functional but violates the established pattern. If someone adds
fetchActivityto the dependency array (following the pattern fromuseTeamPresence), it would cause an infinite re-render loop sincefetchActivityis recreated every render. - Fix: Wrap
fetchActivityinuseCallbackwith[baseUrl, teamId, limit]dependencies and add it to the effect's dependency array.
Overall Frontend Quality Assessment
Strengths
-
TypeScript strict mode is ON in both
app/tsconfig.jsonandpackages/ui/tsconfig.json. Thestrict: true,noUnusedLocals: true, andnoUnusedParameters: trueflags are all enabled. This is excellent. -
Effect cleanup is generally well-handled. Most
setInterval,addEventListener, andEventSourceusages have proper cleanup in theiruseEffectreturn functions. TheuseTeamPresence,useNotifications,useSubAgentStatus,useSessions,useMemory,useWorkspaces, and keyboard handler effects all clean up correctly. -
Hooks extract testable pure functions.
useChatexportsprocessStreamEvent(),useMemoryexportsexecuteMemorySearch(),useKnowledgeGraphextractstoKGData(). This pattern enables unit testing without React. -
useCallbackusage is thorough. The codebase usesuseCallbackextensively for event handlers passed as props — ~72 occurrences across app/src alone. This prevents unnecessary re-renders in child components (though the benefit is limited withoutReact.memo). -
Race condition guards exist in key hooks.
useMemory,useSessions,useWorkspaces, andServiceProviderall usecancelledflags to prevent state updates after unmount. -
Accessibility basics are present. ARIA roles (
role="tablist",role="tab",aria-selected,role="dialog",aria-modal,role="listbox",role="option") are used in Modal, CommandPalette, CapabilitiesView tabs, and similar interactive components. -
Key props on lists are correct. All
.map()calls that render JSX use appropriatekeyprops (workspace IDs, session IDs, pack slugs, package IDs, etc.). No index-only keys on dynamic lists. -
Sanitization is present. Markdown rendering in
ChatMessageusesDOMPurify.sanitize()on the output ofmarked.parse(), preventing XSS through user/agent messages.
Weaknesses
-
No error boundaries — the single most critical gap. A rendering error anywhere crashes the entire app.
-
No
React.memo— combined with the monolithic App component, this means every poll interval (15s, 30s) and every SSE event causes a full tree reconciliation. -
No code splitting — all views are eagerly loaded. For a desktop app this is less critical than web, but it still impacts cold start time.
-
State management is all local — 30+ useState calls in one component with prop drilling through 5 levels. No state management library or context splitting.
-
Dead code accumulation — 7 orphaned files from earlier milestones remain in the tree.
Risk Rating
| Category | Rating |
|---|---|
| Crash resilience | POOR (no error boundaries) |
| Performance | FAIR (no memoization, no splitting, but app is desktop-bound) |
| Type safety | GOOD (strict mode, limited any usage) |
| Memory leak risk | GOOD (cleanup patterns are solid) |
| Maintainability | FAIR (monolithic App, large views, but hooks are well-structured) |
| Security (XSS) | GOOD (DOMPurify in place) |
Overall: FAIR — The codebase has solid foundations (TypeScript strict, effect cleanup, sanitization) but lacks production hardening (error boundaries, performance optimization, code splitting). The most urgent fix is adding error boundaries to prevent white-screen crashes.