Files
waggle-os/docs/production-readiness/03C-UI_QUALITY.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

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 ErrorBoundary or componentDidCatch exists 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 in App(). 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 useNotifications and useSubAgentStatus open independent EventSource connections 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. Both useNotifications and useSubAgentStatus should 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() or Suspense is used anywhere. The Vite config has no manualChunks configuration in rollupOptions. The CapabilitiesView alone is 1227 lines. CockpitView imports 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. Add manualChunks to vite.config.ts to 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: WaggleApp is a single component with ~30 useState hooks, ~15 useEffect hooks, and ~20 useCallback hooks. 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 monolithic WaggleApp component, 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. The ChatView and ContextPanel are also good candidates since they receive many callbacks wrapped in useCallback.

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 the WaggleService type interface but are called through an any cast 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, and listTeams to the WaggleService interface (or a TeamService extension interface) so TypeScript can verify the contract. If these are optional features, use a type guard or feature-detection pattern instead of as 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 by app/src/views/ChatView.tsx which uses @waggle/ui)
    • app/src/hooks/useChat.ts — legacy chat hook using old ipc.sendMessage() (only imported by the orphaned ChatView above)
    • app/src/hooks/useSidecar.ts — legacy service connection hook (replaced by ServiceProvider)
    • app/src/components/layout/Sidebar.tsx — legacy sidebar (replaced by AppSidebar)
    • app/src/components/layout/TitleBar.tsx — legacy title bar
    • app/src/components/onboarding/OnboardingWizard.tsx — legacy onboarding (replaced by @waggle/ui's OnboardingWizard)
    • app/src/components/settings/SettingsPanel.tsx — legacy settings (M1-era, uses old ipc API; the active SettingsView imports 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 legacy SettingsPanel stores API keys in plaintext via /api/settings instead of the vault.
  • Fix: Delete the 7 orphaned files. They are M1/M2-era relics superseded by the @waggle/ui component 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: fetchActivity is defined as a plain async function inside the component body (not wrapped in useCallback). It is then called inside a useEffect that 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 the fetchActivity function returned as refresh from 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 refresh callback 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 fetchActivity in useCallback with [baseUrl, teamId, limit] as dependencies, matching the pattern used in useTeamPresence.

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(). While abortRef.current = true is 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 setMessages call at line 162 could apply stale data.
  • Fix: Add a cancelled flag (like useMemory does) and check it before calling setMessages in the .then() callback. Return a cleanup function that sets cancelled = 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-deps comments suppress dependency warnings. Notable cases:
    • App.tsx:402 — checkPending effect has empty deps [] but references setMessages and SERVER_BASE. While setMessages is stable (from useState), the pattern hides the dependency on SERVER_BASE.
    • App.tsx:774 — Keyboard handler effect is missing handleNewTab from dependencies. If handleNewTab changes (e.g., new workspace selected), the keyboard shortcut will use stale data.
    • useWorkspaces.ts:60 — Missing activeId dependency. Intentional (to avoid re-fetching when active changes) but the lint suppression hides the rationale.
  • 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 handleNewTab to the dependency array. For App.tsx:402, the empty deps are intentional (run once on mount) but should use a ref for SERVER_BASE or document the intentionality with a comment. For useWorkspaces.ts:60, document why activeId is 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 setTimeout outside of useEffect, 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 useEffect with cleanup for timer-based state resets. Alternatively, use a custom useTimeout hook 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 function listeners.forEach(unlisten => unlisten()) runs synchronously when the component unmounts. However, if the component unmounts before the await listen(...) calls complete, the listeners will be pushed to the listeners array 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://quit listener could fire multiple times. In production with stable mounts this is unlikely, but it is architecturally unsound.
  • Fix: Add a cancelled flag. Check cancelled before pushing to listeners. In the cleanup, both set cancelled = true and 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.ts but never imported by any consumer. The app/src/App.tsx manages active workspace state directly via useWorkspaces which returns activeWorkspace and setActiveWorkspace.
  • 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.tsx to 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 debounceRef stores a setTimeout reference for search debouncing. While individual timeouts are cleared when new input arrives (line 47), there is no useEffect cleanup 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 onSearch callback 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 in ServiceProvider.tsx (loading/error screens use pure inline styles instead of Tailwind classes). The ToolCard uses inline styles for transition animations. ToastContainer uses 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 style combined 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 of catch (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 err is unchecked, so accessing err.message on a non-Error throw would silently produce undefined instead of failing at compile time.
  • Fix: Change to catch (err: unknown) and use err 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) — single LocalAdapter instance created at module load time
    • dismissedPatterns (useSubAgentStatus.ts:37) — Set<string> shared across all hook instances
    • messageIdCounter (useChat.ts:29) — global counter for message IDs
  • Impact: In testing or SSR contexts, these singletons persist across renders/tests. The dismissedPatterns set grows unboundedly throughout the app's lifetime (minor memory concern). The adapter being module-level means it cannot be reconfigured without a page reload.
  • Fix: For adapter, this is acceptable for a desktop app (single instance). For dismissedPatterns, consider a WeakMap or periodic cleanup. For messageIdCounter, the pattern is safe but could use crypto.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+ useState calls, 10+ useCallback hooks, 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 === 0 as a guard and always takes notifications[0] (the latest). However, the notifications array from useNotifications accumulates up to 50 items. If the notifications state 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 useEffect at line 51 has [baseUrl, teamId, limit] in its dependency array, but it calls fetchActivity() which is a plain function (not memoized). ESLint would flag fetchActivity as a missing dependency, but the lint rule isn't running. The function works correctly because it captures baseUrl, teamId, limit from closure, but the pattern is fragile and inconsistent with other hooks that use useCallback for fetch functions.
  • Impact: Functional but violates the established pattern. If someone adds fetchActivity to the dependency array (following the pattern from useTeamPresence), it would cause an infinite re-render loop since fetchActivity is recreated every render.
  • Fix: Wrap fetchActivity in useCallback with [baseUrl, teamId, limit] dependencies and add it to the effect's dependency array.

Overall Frontend Quality Assessment

Strengths

  1. TypeScript strict mode is ON in both app/tsconfig.json and packages/ui/tsconfig.json. The strict: true, noUnusedLocals: true, and noUnusedParameters: true flags are all enabled. This is excellent.

  2. Effect cleanup is generally well-handled. Most setInterval, addEventListener, and EventSource usages have proper cleanup in their useEffect return functions. The useTeamPresence, useNotifications, useSubAgentStatus, useSessions, useMemory, useWorkspaces, and keyboard handler effects all clean up correctly.

  3. Hooks extract testable pure functions. useChat exports processStreamEvent(), useMemory exports executeMemorySearch(), useKnowledgeGraph extracts toKGData(). This pattern enables unit testing without React.

  4. useCallback usage is thorough. The codebase uses useCallback extensively 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 without React.memo).

  5. Race condition guards exist in key hooks. useMemory, useSessions, useWorkspaces, and ServiceProvider all use cancelled flags to prevent state updates after unmount.

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

  7. Key props on lists are correct. All .map() calls that render JSX use appropriate key props (workspace IDs, session IDs, pack slugs, package IDs, etc.). No index-only keys on dynamic lists.

  8. Sanitization is present. Markdown rendering in ChatMessage uses DOMPurify.sanitize() on the output of marked.parse(), preventing XSS through user/agent messages.

Weaknesses

  1. No error boundaries — the single most critical gap. A rendering error anywhere crashes the entire app.

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

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

  4. State management is all local — 30+ useState calls in one component with prop drilling through 5 levels. No state management library or context splitting.

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