25 KiB
Frontend AGENTS.md
This document is the frontend working guide for agents and developers.
Keep it aligned with frontend/src source code.
Stack
- React 18 + TypeScript
- Vite
- Vitest + Testing Library
- shadcn/ui primitives
- Tailwind utility classes + local CSS (
index.css,styles.css) - Sonner (toasts)
- Leaflet / react-leaflet (map)
@michaelhart/meshcore-decoderinstalled via npm alias tomeshcore-decoder-multibyte-patchmeshcore-hashtag-cracker+nosleep.js(channel cracker)- Multibyte-aware decoder build published as
meshcore-decoder-multibyte-patch
Code Ethos
- Prefer fewer, stronger modules over many thin wrappers.
- Split code only when the new hook/component owns a real invariant or workflow.
- Keep one reasoning unit readable in one place, even if that file is moderately large.
- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming.
- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture".
- When refactoring, preserve behavior first and add tests around the seam being moved.
Frontend Map
frontend/src/
├── main.tsx # React entry point (StrictMode, root render)
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
├── api.ts # Typed REST client
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
├── wsEvents.ts # Typed WS event parsing / discriminated union
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
│ ├── index.ts # Central re-export of all hooks
│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions
│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state
│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
├── components/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ ├── visualizer/
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay
│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay
│ │ └── shared.ts # Graph node/link types and shared rendering helpers
│ └── ...
├── utils/
│ ├── urlHash.ts # Hash parsing and encoding
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
│ ├── favorites.ts # LocalStorage migration for favorites
│ ├── messageParser.ts # Message text → rendered segments
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
│ ├── radioPresets.ts # LoRa radio preset configurations
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
│ ├── ChatHeader.tsx # Conversation header (trace, favorite, delete)
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
│ ├── VisualizerView.tsx
│ ├── PacketVisualizer3D.tsx
│ ├── PathModal.tsx
│ ├── PathRouteMap.tsx
│ ├── CrackerPanel.tsx
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── ContactStatusInfo.tsx # Contact status info component
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
│ ├── repeater/
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
│ │ ├── RepeaterAclPane.tsx # Permission table
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
├── setup.ts
├── fixtures/websocket_events.json
├── api.test.ts
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
├── mapView.test.tsx
├── messageCache.test.ts
├── messageList.test.tsx
├── messageParser.test.ts
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── radioPresets.test.ts
├── rawPacketIdentity.test.ts
├── repeaterDashboard.test.tsx
├── repeaterFormatters.test.ts
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── localLabel.test.ts
├── messageInput.test.tsx
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationActions.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
├── useAppShell.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
├── useRealtimeAppState.test.ts
├── useUnreadCounts.test.ts
├── useWebSocket.dispatch.test.ts
├── useWebSocket.lifecycle.test.ts
└── wsEvents.test.ts
Architecture Notes
State ownership
App.tsx is now a thin composition entrypoint over the hook layer. AppShell.tsx owns shell layout/composition:
- local label banner
- status bar
- desktop/mobile sidebar container
- search/settings surface switching
- global cracker mount/focus behavior
- new-message modal and info panes
High-level state is delegated to hooks:
useAppShell: app-shell view state (settings section, sidebar, cracker, new-message modal)useRadioControl: radio health/config state, reconnect/reboot pollinguseAppSettings: settings CRUD, favorites, preferences migrationuseContactsAndChannels: contact/channel lists, creation, deletionuseConversationRouter: URL hash → active conversation routinguseConversationNavigation: search target, conversation selection reset, and info-pane stateuseConversationActions: send/resend/trace/path-discovery/block handlers and channel override updatesuseConversationMessages: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK bufferinguseUnreadCounts: unread counters, mention tracking, recent-sort timestamps, and serverlast_read_atsboundariesuseRealtimeAppState: typed WS event application, reconnect recovery, cache/unread coordinationuseRepeaterDashboard: repeater dashboard state (login, pane data/retries, console, actions)
App.tsx intentionally still does the final AppShell prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic.
ConversationPane.tsx owns the main active-conversation surface branching:
- empty state
- map view
- visualizer
- raw packet feed
- repeater dashboard
- normal chat chrome (
ChatHeader+MessageList+MessageInput)
Initial load + realtime
- Initial data: REST fetches (
api.ts) for config/settings/channels/contacts/unreads. - WebSocket: realtime deltas/events.
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
- On WS connect, backend sends
healthonly; contacts/channels still come from REST.
New Message modal
NewMessageModal resets form state on close. The component instance persists across open/close cycles for smooth animations.
Message behavior
- Outgoing sends are added to UI after the send API returns (not pre-send optimistic insertion), then persisted server-side.
- Backend also emits WS
messagefor outgoing sends so other clients stay in sync. - ACK/repeat updates arrive as
message_ackedevents. - Outgoing channel messages show a 30-second resend control; resend calls
POST /api/messages/channel/{message_id}/resend. - Conversation-scoped message caching now lives inside
useConversationMessages.tsrather than a standalonemessageCache.tsmodule. If you touch message timeline restore/dedup/reconnect behavior, start there. contact_resolvedis a real-time identity migration event, not just a contact-list update. Changes in that area need to consider active conversation state, cached messages, unread state keys, and reconnect reconciliation together.
Visualizer behavior
VisualizerView.tsxhostsPacketVisualizer3D.tsx(desktop split-pane and mobile tabs).PacketVisualizer3D.tsxis now a thin composition shell over visualizer-specific hooks/components incomponents/visualizer/.PacketVisualizer3Duses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.- Packet repeat aggregation keys prefer decoder
messageHash(path-insensitive), with hash fallback for malformed packets. - Raw-packet decoding in
RawPacketList.tsxandvisualizerUtils.tsrelies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backendpath_utils.py. - Raw packet events carry both:
id: backend storage row identity (payload-level dedup)observation_id: realtime per-arrival identity (session fidelity)
- Packet feed/visualizer render keys and dedup logic should use
observation_id(fallback toidonly for older payloads). - The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
Radio settings behavior
SettingsRadioSection.tsxsurfacespath_hash_modeonly whenconfig.path_hash_mode_supportedis true.SettingsRadioSection.tsxalso exposesmulti_acks_enabledas a checkbox for the radio's extra direct-ACK transmission behavior.- Advert-location control is intentionally only
offvsinclude node location. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path. - The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same
onAdvertise(mode)seam. - Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- Frontend
path_lenfields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
WebSocket (useWebSocket.ts)
- Auto reconnect (3s) with cleanup guard on unmount.
- Heartbeat ping every 30s.
- Incoming JSON is parsed through
wsEvents.ts, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes. - Event handlers:
health,message,contact,contact_resolved,channel,raw_packet,message_acked,contact_deleted,channel_deleted,error,success,pong(ignored). - For
raw_packetevents, useobservation_idas event identity;idis a storage reference and may repeat.
URL Hash Navigation (utils/urlHash.ts)
Supported routes:
#raw#map#map/focus/{pubkey_or_prefix}#visualizer#search#channel/{channelKey}#channel/{channelKey}/{label}#contact/{publicKey}#contact/{publicKey}/{label}
Legacy name-based hashes are still accepted for compatibility.
Conversation State Keys (utils/conversationState.ts)
getStateKey(type, id) produces:
- channels:
channel-{channelKey} - contacts:
contact-{publicKey}
Use full contact public key here (not 12-char prefix).
conversationState.ts keeps an in-memory cache and localStorage helpers used for migration/compatibility.
Canonical persistence for unread and sort metadata is server-side (app_settings + read-state endpoints).
Utilities
utils/pubkey.ts
Current public export:
getContactDisplayName(name, pubkey)
It falls back to a 12-char prefix when name is missing.
utils/pathUtils.ts
Distance/validation helpers used by path + map UI.
utils/favorites.ts
LocalStorage migration helpers for favorites; canonical favorites are server-side.
Types and Contracts (types.ts)
AppSettings currently includes:
max_radio_contactsfavoritesauto_decrypt_dm_on_advertsidebar_sort_orderlast_message_timespreferences_migratedadvert_intervallast_advert_timeflood_scopeblocked_keys,blocked_names
The backend still carries sidebar_sort_order for compatibility and old preference migration, but the current sidebar UI stores sort order per section (Channels, Contacts, Repeaters) in frontend localStorage rather than treating it as one global server-backed setting.
Note: MQTT, bot, and community MQTT settings were migrated to the fanout_configs table (managed via /api/fanout). They are no longer part of AppSettings.
HealthStatus includes fanout_statuses: Record<string, FanoutStatusEntry> mapping config IDs to {name, type, status}. Also includes bots_disabled: boolean.
FanoutConfig represents a single fanout integration: {id, type, name, enabled, config, scope, sort_order, created_at}.
RawPacket.decrypted_info includes channel_key and contact_key for MQTT topic routing.
UnreadCounts includes counts, mentions, last_message_times, and last_read_ats. The unread-boundary/jump-to-unread behavior uses the server-provided last_read_ats map keyed by getStateKey(...).
Contact Info Pane
Clicking a contact's avatar in ChatHeader or MessageList opens a ContactInfoPane sheet (right drawer) showing comprehensive contact details fetched from GET /api/contacts/analytics using either ?public_key=... or ?name=...:
- Header: avatar, name, public key, type badge, on-radio badge
- Info grid: last seen, first heard, last contacted, distance, hops
- GPS location (clickable → map)
- Favorite toggle
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (
effective_route,effective_route_source,direct_route,route_override) - Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths (informational only; not part of DM route selection)
State: useConversationNavigation controls open/close via infoPaneContactKey. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
Channel Info Pane
Clicking a channel name in ChatHeader opens a ChannelInfoPane sheet (right drawer) showing channel details fetched from GET /api/channels/{key}/detail:
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
- Favorite toggle
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
- First message date
- Top senders in last 24h (name + count)
State: useConversationNavigation controls open/close via infoPaneChannelKey. Live channel data from the channels array is preferred over the initial detail snapshot.
Repeater Dashboard
For repeater contacts (type=2), ConversationPane.tsx renders RepeaterDashboard instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
Login: RepeaterLogin component — password or guest login via POST /api/contacts/{key}/repeater/login.
Dashboard panes (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular POST /api/contacts/{key}/repeater/{pane} endpoints. Panes retry up to 3 times client-side. Neighbors depends on the smaller node-info fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
Actions pane: Send Advert, Sync Clock, Reboot — all send CLI commands via POST /api/contacts/{key}/command.
Console pane: Full CLI access via the same command endpoint. History is ephemeral (not persisted to DB).
All state is managed by useRepeaterDashboard hook. State resets on conversation change.
Message Search Pane
The SearchView component (components/SearchView.tsx) provides full-text search across all DMs and channel messages. Key behaviors:
- State:
targetMessageIdis shared betweenuseConversationNavigationanduseConversationMessages. When a search result is clicked,handleNavigateToMessagesets the target ID and switches to the target conversation. - Same-conversation clear: when
targetMessageIdis cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - Persistence:
SearchViewstays mounted after first open using the samehiddenclass pattern asCrackerPanel, preserving search state when navigating to results. - Jump-to-message:
useConversationMessageshandles optionaltargetMessageIdby callingapi.getMessagesAround()instead of the normal latest-page fetch, loading context around the target message.MessageListscrolls to the target viadata-message-idattribute and applies amessage-highlightCSS animation. - Bidirectional pagination: After jumping mid-history,
hasNewerMessagesenables forward pagination viafetchNewerMessages. The scroll-to-bottom button callsjumpToBottom(re-fetches latest page) instead of just scrolling. - WS message suppression: When
hasNewerMessagesis true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in index.css and styles.css.
Do not rely on old class-only layout assumptions.
Security Posture (intentional)
- No authentication UI.
- Frontend assumes trusted network usage.
- Bot editor intentionally allows arbitrary backend bot code configuration.
Testing
Run all quality checks (backend + frontend) from the repo root:
./scripts/all_quality.sh
Or run frontend checks individually:
cd frontend
npm run test:run
npm run build
npm run packaged-build is release-only. It writes the fallback frontend/prebuilt
directory used by the downloadable prebuilt release zip; normal development and
validation should stick to npm run build.
When touching cross-layer contracts, also run backend tests from repo root:
PYTHONPATH=. uv run pytest tests/ -v
Errata & Known Non-Issues
Contacts use mention styling for unread DMs
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real @[name] mentions.
RawPacketList always scrolls to bottom
RawPacketList unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position.
Editing Checklist
- If API/WS payloads change, update
types.ts, handlers, and tests. - If URL/hash behavior changes, update
utils/urlHash.tstests. - If read/unread semantics change, update
useUnreadCountstests. - Keep this file concise; prefer source links over speculative detail.