diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 6531178..81ab0eb 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -45,6 +45,9 @@ frontend/src/
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
+├── components/
+│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
+│ └── ...
├── utils/
│ ├── urlHash.ts # Hash parsing and encoding
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
@@ -166,6 +169,14 @@ frontend/src/
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
+`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.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 51664c9..23a321b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,13 +1,4 @@
-import {
- useState,
- useEffect,
- useCallback,
- useMemo,
- useRef,
- startTransition,
- lazy,
- Suspense,
-} from 'react';
+import { useState, useEffect, useCallback, useRef, startTransition, lazy, Suspense } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,28 +14,16 @@ import {
} from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
-import { ChatHeader } from './components/ChatHeader';
-import { MessageList } from './components/MessageList';
-import { MessageInput, type MessageInputHandle } from './components/MessageInput';
+import { ConversationPane } from './components/ConversationPane';
+import type { MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
} from './components/settings/settingsConstants';
-import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { ChannelInfoPane } from './components/ChannelInfoPane';
-import { CONTACT_TYPE_REPEATER } from './types';
-
-// Lazy-load heavy components to reduce initial bundle
-const RepeaterDashboard = lazy(() =>
- import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
-);
-const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
-const VisualizerView = lazy(() =>
- import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView }))
-);
const SettingsModal = lazy(() =>
import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal }))
);
@@ -209,13 +188,6 @@ export function App() {
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
- // Determine if active contact is a repeater (used for routing to dashboard)
- const activeContactIsRepeater = useMemo(() => {
- if (!activeConversation || activeConversation.type !== 'contact') return false;
- const contact = contacts.find((c) => c.public_key === activeConversation.id);
- return contact?.type === CONTACT_TYPE_REPEATER;
- }, [activeConversation, contacts]);
-
const wsHandlers = useRealtimeAppState({
prevHealthRef,
setHealth,
@@ -431,123 +403,37 @@ export function App() {
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
- {activeConversation ? (
- activeConversation.type === 'map' ? (
- <>
-
- Node Map
-
-
-
- Loading map...
-
- }
- >
-
-
-
- >
- ) : activeConversation.type === 'visualizer' ? (
-
- Loading visualizer...
-
- }
- >
-
-
- ) : activeConversation.type === 'raw' ? (
- <>
-
- Raw Packet Feed
-
-
-
-
- >
- ) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? (
-
- Loading dashboard...
-
- }
- >
-
-
- ) : (
- <>
-
- setTargetMessageId(null)}
- hasNewerMessages={hasNewerMessages}
- loadingNewer={loadingNewer}
- onLoadNewer={fetchNewerMessages}
- onJumpToBottom={jumpToBottom}
- />
-
- >
- )
- ) : (
-
- Select a conversation or start a new one
-
- )}
+ setTargetMessageId(null)}
+ onLoadNewer={fetchNewerMessages}
+ onJumpToBottom={jumpToBottom}
+ onSendMessage={handleSendMessage}
+ />
{searchMounted.current && (
diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx
new file mode 100644
index 0000000..5def504
--- /dev/null
+++ b/frontend/src/components/ConversationPane.tsx
@@ -0,0 +1,219 @@
+import { lazy, Suspense, useMemo, type Ref } from 'react';
+
+import { ChatHeader } from './ChatHeader';
+import { MessageInput, type MessageInputHandle } from './MessageInput';
+import { MessageList } from './MessageList';
+import { RawPacketList } from './RawPacketList';
+import type {
+ Channel,
+ Contact,
+ Conversation,
+ Favorite,
+ HealthStatus,
+ Message,
+ RawPacket,
+ RadioConfig,
+} from '../types';
+import { CONTACT_TYPE_REPEATER } from '../types';
+
+const RepeaterDashboard = lazy(() =>
+ import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
+);
+const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView })));
+const VisualizerView = lazy(() =>
+ import('./VisualizerView').then((m) => ({ default: m.VisualizerView }))
+);
+
+interface ConversationPaneProps {
+ activeConversation: Conversation | null;
+ contacts: Contact[];
+ channels: Channel[];
+ rawPackets: RawPacket[];
+ config: RadioConfig | null;
+ health: HealthStatus | null;
+ favorites: Favorite[];
+ messages: Message[];
+ messagesLoading: boolean;
+ loadingOlder: boolean;
+ hasOlderMessages: boolean;
+ targetMessageId: number | null;
+ hasNewerMessages: boolean;
+ loadingNewer: boolean;
+ messageInputRef: Ref;
+ onTrace: () => Promise;
+ onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise;
+ onDeleteContact: (publicKey: string) => Promise;
+ onDeleteChannel: (key: string) => Promise;
+ onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise;
+ onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
+ onOpenChannelInfo: (channelKey: string) => void;
+ onSenderClick: (sender: string) => void;
+ onLoadOlder: () => Promise;
+ onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise;
+ onTargetReached: () => void;
+ onLoadNewer: () => Promise;
+ onJumpToBottom: () => void;
+ onSendMessage: (text: string) => Promise;
+}
+
+function LoadingPane({ label }: { label: string }) {
+ return (
+ {label}
+ );
+}
+
+export function ConversationPane({
+ activeConversation,
+ contacts,
+ channels,
+ rawPackets,
+ config,
+ health,
+ favorites,
+ messages,
+ messagesLoading,
+ loadingOlder,
+ hasOlderMessages,
+ targetMessageId,
+ hasNewerMessages,
+ loadingNewer,
+ messageInputRef,
+ onTrace,
+ onToggleFavorite,
+ onDeleteContact,
+ onDeleteChannel,
+ onSetChannelFloodScopeOverride,
+ onOpenContactInfo,
+ onOpenChannelInfo,
+ onSenderClick,
+ onLoadOlder,
+ onResendChannelMessage,
+ onTargetReached,
+ onLoadNewer,
+ onJumpToBottom,
+ onSendMessage,
+}: ConversationPaneProps) {
+ const activeContactIsRepeater = useMemo(() => {
+ if (!activeConversation || activeConversation.type !== 'contact') return false;
+ const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
+ return contact?.type === CONTACT_TYPE_REPEATER;
+ }, [activeConversation, contacts]);
+
+ if (!activeConversation) {
+ return (
+
+ Select a conversation or start a new one
+
+ );
+ }
+
+ if (activeConversation.type === 'map') {
+ return (
+ <>
+
+ Node Map
+
+
+ }>
+
+
+
+ >
+ );
+ }
+
+ if (activeConversation.type === 'visualizer') {
+ return (
+ }>
+
+
+ );
+ }
+
+ if (activeConversation.type === 'raw') {
+ return (
+ <>
+
+ Raw Packet Feed
+
+
+
+
+ >
+ );
+ }
+
+ if (activeConversation.type === 'search') {
+ return null;
+ }
+
+ if (activeContactIsRepeater) {
+ return (
+ }>
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx
new file mode 100644
index 0000000..9f35335
--- /dev/null
+++ b/frontend/src/test/conversationPane.test.tsx
@@ -0,0 +1,195 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConversationPane } from '../components/ConversationPane';
+import type {
+ Channel,
+ Contact,
+ Conversation,
+ Favorite,
+ HealthStatus,
+ Message,
+ RadioConfig,
+} from '../types';
+
+vi.mock('../components/ChatHeader', () => ({
+ ChatHeader: () => ,
+}));
+
+vi.mock('../components/MessageList', () => ({
+ MessageList: () => ,
+}));
+
+vi.mock('../components/MessageInput', () => ({
+ MessageInput: React.forwardRef((_props, ref) => {
+ React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
+ return ;
+ }),
+}));
+
+vi.mock('../components/RawPacketList', () => ({
+ RawPacketList: () => ,
+}));
+
+vi.mock('../components/RepeaterDashboard', () => ({
+ RepeaterDashboard: () => ,
+}));
+
+vi.mock('../components/MapView', () => ({
+ MapView: () => ,
+}));
+
+vi.mock('../components/VisualizerView', () => ({
+ VisualizerView: () => ,
+}));
+
+const config: RadioConfig = {
+ public_key: 'aa'.repeat(32),
+ name: 'Radio',
+ lat: 1,
+ lon: 2,
+ tx_power: 17,
+ max_tx_power: 22,
+ radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
+ path_hash_mode: 0,
+ path_hash_mode_supported: true,
+};
+
+const health: HealthStatus = {
+ status: 'ok',
+ radio_connected: true,
+ radio_initializing: false,
+ connection_info: 'serial',
+ database_size_mb: 1,
+ oldest_undecrypted_timestamp: null,
+ fanout_statuses: {},
+ bots_disabled: false,
+};
+
+const channel: Channel = {
+ key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
+ name: 'Public',
+ is_hashtag: false,
+ on_radio: false,
+ last_read_at: null,
+};
+
+const message: Message = {
+ id: 1,
+ type: 'CHAN',
+ conversation_key: channel.key,
+ text: 'hello',
+ sender_timestamp: 1700000000,
+ received_at: 1700000001,
+ paths: null,
+ txt_type: 0,
+ signature: null,
+ sender_key: null,
+ outgoing: false,
+ acked: 0,
+ sender_name: null,
+};
+
+function createProps(overrides: Partial> = {}) {
+ return {
+ activeConversation: null as Conversation | null,
+ contacts: [] as Contact[],
+ channels: [channel],
+ rawPackets: [],
+ config,
+ health,
+ favorites: [] as Favorite[],
+ messages: [message],
+ messagesLoading: false,
+ loadingOlder: false,
+ hasOlderMessages: false,
+ targetMessageId: null,
+ hasNewerMessages: false,
+ loadingNewer: false,
+ messageInputRef: { current: null },
+ onTrace: vi.fn(async () => {}),
+ onToggleFavorite: vi.fn(async () => {}),
+ onDeleteContact: vi.fn(async () => {}),
+ onDeleteChannel: vi.fn(async () => {}),
+ onSetChannelFloodScopeOverride: vi.fn(async () => {}),
+ onOpenContactInfo: vi.fn(),
+ onOpenChannelInfo: vi.fn(),
+ onSenderClick: vi.fn(),
+ onLoadOlder: vi.fn(async () => {}),
+ onResendChannelMessage: vi.fn(async () => {}),
+ onTargetReached: vi.fn(),
+ onLoadNewer: vi.fn(async () => {}),
+ onJumpToBottom: vi.fn(),
+ onSendMessage: vi.fn(async () => {}),
+ ...overrides,
+ };
+}
+
+describe('ConversationPane', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the empty state when no conversation is active', () => {
+ render();
+
+ expect(screen.getByText('Select a conversation or start a new one')).toBeInTheDocument();
+ });
+
+ it('renders repeater dashboard instead of chat chrome for repeater contacts', async () => {
+ render(
+
+ );
+
+ expect(await screen.findByTestId('repeater-dashboard')).toBeInTheDocument();
+ expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
+ });
+
+ it('renders chat chrome for normal channel conversations', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('chat-header')).toBeInTheDocument();
+ expect(screen.getByTestId('message-list')).toBeInTheDocument();
+ expect(screen.getByTestId('message-input')).toBeInTheDocument();
+ });
+ });
+});