# UI Components
## Architecture Overview
MiniSearch uses a **PubSub-based reactive architecture**. Components subscribe to state changes via channels rather than props drilling or Context API.
### PubSub Pattern
Each PubSub channel is a three-element tuple returned by `createPubSub`:
| Index | Name | Role |
|-------|------|------|
| `[0]` | `update*` | Setter — publishes a new value to all subscribers |
| `[1]` | `onValueChange` / `subscribe*` / `listen*` | Subscription registration — receives every future value |
| `[2]` | `get*` | Getter — reads the current value synchronously |
These are destructured at module level in `pubSub.ts` and exported under descriptive names (e.g., `updateTextGenerationState`, `listenToSettingsChanges`, `getQuery`).
```typescript
// Component subscribes to state
const query = usePubSub(queryPubSub);
// Any module can update state
updateQuery('new query');
// All subscribers automatically re-render
```
**Benefits:**
- **Decoupling** — Modules (text generation, search, React components) read and write shared state without importing each other directly
- **No provider boilerplate** — Unlike React Context or Redux, no `Provider` wrapper needed; any module imports a channel from `pubSub.ts`
- **Selective subscriptions** — Components subscribe only to channels they use; a streaming token update throttling `responsePubSub` does not trigger re-renders in unrelated components
- **Persistence as a decorator** — `createLocalStoragePubSub` layers persistence transparently onto the same interface; consumers don't need to know whether a channel is persisted or ephemeral
### localStorage Persistence
Some state must survive page reloads. The `createLocalStoragePubSub` helper wraps `createPubSub` with two behaviors:
1. **Hydration** — On first call, reads existing value from `localStorage` via `localStorage.getItem(key)`. If a stored JSON string is found, it is parsed and used as the initial value; otherwise the default value is used
2. **Persistence** — A subscriber is immediately registered on the inner PubSub that calls `localStorage.setItem` with the JSON-serialized new value on every state change
Channels using this pattern: `settingsPubSub`, `querySuggestionsPubSub`, `lastSearchTokenHashPubSub`, `menuExpandedAccordionsPubSub`.
### Throttling High-Frequency Updates
Streaming LLM output produces token-by-token state changes that would overwhelm React's rendering pipeline. Two channels apply `throttle` from `throttleit` to cap subscriber notification rate to ~12 updates/sec (83.3ms interval):
| Export | Raw Updater | Throttle Interval |
|--------|-------------|-------------------|
| `updateResponse` | `responsePubSub[0]` | 1000 / 12 ms |
| `updateReasoningContent` | `reasoningContentPubSub[0]` | 1000 / 12 ms |
Callers write streaming token output directly to these exports without awareness of internal throttling.
### Side Effects
Three channels register built-in side-effect subscribers at module load time, independently of any React component lifecycle, for automatic logging via `addLogEntry`:
| Channel | Side Effect |
|---------|-------------|
| `textGenerationStatePubSub` | Logs state transitions |
| `textSearchStatePubSub` | Logs state transitions |
| `imageSearchStatePubSub` | Logs state transitions |
## PubSub Channel Reference
All state channels are defined in `client/modules/pubSub.ts`:
| Channel | Type | Description | Primary Consumers |
|---------|------|-------------|-------------------|
| `queryPubSub` | `string` | Current search query | SearchForm, SearchButton |
| `responsePubSub` | `string` | AI response content (throttled: 12/sec) | AiResponseSection |
| `reasoningContentPubSub` | `string` | AI reasoning/thinking content (throttled: 12/sec) | AiResponseSection |
| `settingsPubSub` | `Settings` | Application settings | SettingsForm, various components |
| `textSearchResultsPubSub` | `TextSearchResults` | Text search results | SearchResultsSection |
| `llmTextSearchResultsPubSub` | `TextSearchResults` | LLM-reranked text results | Internal use |
| `imageSearchResultsPubSub` | `ImageSearchResults` | Image search results | ImageResultsSection |
| `textSearchStatePubSub` | `SearchState` | Text search state: `"idle" \| "running" \| "failed" \| "completed"` | SearchResultsSection, LoadingIndicators |
| `imageSearchStatePubSub` | `SearchState` | Image search state: `"idle" \| "running" \| "failed" \| "completed"` | ImageResultsSection |
| `textGenerationStatePubSub` | `TextGenerationState` | AI generation state | AiResponseSection, StatusIndicators |
| `modelLoadingProgressPubSub` | `number` | Model download progress (0-100) | AiResponseSection |
| `modelSizeInMegabytesPubSub` | `number` | Model size in MB for progress calc | AiResponseSection |
| `chatMessagesPubSub` | `ChatMessage[]` | Chat conversation | ChatInterface |
| `chatInputPubSub` | `string` | Current chat input content | ChatInputArea |
| `chatGenerationStatePubSub` | `{isGeneratingResponse, isGeneratingFollowUpQuestion}` | Chat generation states | ChatInterface |
| `conversationSummaryPubSub` | `{id, summary}` | Rolling conversation summary | TextGeneration module |
| `followUpQuestionPubSub` | `string` | Generated follow-up question | AiResponseSection |
| `suppressNextFollowUpPubSub` | `boolean` | Flag to skip next follow-up | FollowUpQuestions module |
| `isRestoringFromHistoryPubSub` | `boolean` | History restoration in progress | SearchForm, components |
| `menuExpandedAccordionsPubSub` | `string[]` | Expanded menu accordion IDs | MenuDrawer |
| `querySuggestionsPubSub` | `string[]` | Query suggestions history | SearchForm |
| `lastSearchTokenHashPubSub` | `string` | Hash of last search token | Security/validation |
| `logEntriesPubSub` | `LogEntry[]` | Application log entries | LogsModal, ShowLogsButton |
## Component Hierarchy
```
App
├── AccessPage (if access keys enabled)
└── MainPage
├── SearchForm
│ ├── HistoryButton
│ │ └── HistoryDrawer (lazy-loaded)
│ └── MenuButton
│ └── MenuDrawer
│ ├── AISettingsForm
│ ├── SearchSettingsForm
│ ├── InterfaceSettingsForm
│ ├── HistorySettings
│ ├── VoiceSettingsForm
│ └── ActionsForm
├── SearchResultsSection
│ ├── TextSearchResults
│ │ └── SearchResultsList
│ └── ImageSearchResults
│ └── ImageResultsList
└── AiResponseSection
├── AiResponseContent
│ └── FormattedMarkdown
└── ChatInterface (shown once generation is completed)
├── ChatHeader
├── MessageList
└── ChatInputArea
```
## Key Components
### App (`client/components/App/`)
**Responsibility:** Application shell and routing
**Logic:**
```typescript
// App.tsx
// Access key validation is handled via useAccessKeyValidation hook
// which checks localStorage for a stored key hash and verifies it server-side
// Renders if validation fails, otherwise
```
**Subscribes to:** None directly; validation state managed by `useAccessKeyValidation` hook
### SearchForm (`client/components/Search/Form/`)
**Responsibility:** Query input and search initiation
**PubSub:**
- **Subscribes:** `queryPubSub`, `textSearchStatePubSub`
- **Updates:** `queryPubSub` (on type), triggers `searchAndRespond()` (on submit)
**Logic:**
```typescript
function SearchForm() {
const [query, setQuery] = usePubSub(queryPubSub);
const [searchState] = usePubSub(textSearchStatePubSub);
const handleSubmit = () => {
searchAndRespond();
};
return (
);
}
```
### SearchResultsSection (`client/components/Search/Results/`)
**Responsibility:** Display search results (text and images)
**PubSub:**
- **Subscribes:** `textSearchResultsPubSub`, `imageSearchResultsPubSub`, `textSearchStatePubSub`
**Logic:**
```typescript
function SearchResultsSection() {
const [textResults] = usePubSub(textSearchResultsPubSub);
const [imageResults] = usePubSub(imageSearchResultsPubSub);
const [searchState] = usePubSub(textSearchStatePubSub);
if (searchState === 'running') return ;
if (searchState === 'failed') return ;
return (
<>
{settings.enableImageSearch && }
>
);
}
```
### AiResponseSection (`client/components/AiResponse/`)
**Responsibility:** AI response display and chat interface
**PubSub:**
- **Subscribes:** `responsePubSub`, `textGenerationStatePubSub`, `chatMessagesPubSub`
**State Machine (`textGenerationStatePubSub`):**
| State | Description | UI Display |
|-------|-------------|------------|
| `idle` | No active generation | Hidden or empty |
| `awaitingModelDownloadAllowance` | Waiting for user to confirm model download | `AiModelDownloadAllowanceContent` confirmation prompt |
| `loadingModel` | Downloading or initializing AI model | `LoadingModelContent` with progress |
| `awaitingSearchResults` | Waiting for search to complete | `PreparingContent` indicator |
| `preparingToGenerate` | Search done, response not yet started | `PreparingContent` indicator |
| `generating` | Streaming response tokens | Active response with streaming text |
| `interrupted` | Generation manually stopped by user | Response retained, with a yellow "Interrupted" badge |
| `completed` | Full response received | Complete response with chat interface |
| `failed` | Error occurred | Error message with retry option |
**Reasoning Content Extraction:**
When models output internal thought processes, the UI extracts reasoning content bounded by `reasoningStartMarker` and `reasoningEndMarker` markers. Reasoning is displayed separately from the final response in a collapsible section.
**Logic:**
```typescript
function AiResponseSection() {
const [response] = usePubSub(responsePubSub);
const [textGenerationState] = usePubSub(textGenerationStatePubSub);
const [chatMessages] = usePubSub(chatMessagesPubSub);
if (["generating", "interrupted", "completed", "failed"].includes(textGenerationState)) {
return (
<>
{textGenerationState === "completed" && (
)}
>
);
}
if (textGenerationState === "loadingModel") return ;
if (["preparingToGenerate", "awaitingSearchResults"].includes(textGenerationState)) {
return ;
}
if (textGenerationState === "awaitingModelDownloadAllowance") {
return ;
}
return null;
}
```
### MenuDrawer (`client/components/Pages/Main/Menu/`)
**Responsibility:** Application settings UI
**Sub-components:**
- **AISettingsForm:** Model selection, inference type, reasoning markers (sampling parameters such as temperature are hardcoded, not user-configurable)
- **SearchSettingsForm:** Result limits, image search toggle
- **InterfaceSettingsForm:** UI preferences
- **HistorySettings:** Retention days, max entries
- **VoiceSettingsForm:** TTS voice selection
- **ActionsForm:** Data management actions
**PubSub:**
- **Subscribes/Updates:** `settingsPubSub` (full settings object)
**Persistence:**
```typescript
// client/modules/pubSub.ts
export const settingsPubSub = createLocalStoragePubSub('settings', defaultSettings);
```
`createLocalStoragePubSub` registers the localStorage-writing subscriber internally, so components just read and write `settingsPubSub` like any other channel.
### HistoryDrawer (`client/components/Search/History/`)
**Responsibility:** Search history display and management
**PubSub:**
- **Subscribes:** History loaded from IndexedDB (not via PubSub, via custom hook)
**Hook:** `useSearchHistory()`
```typescript
const {
filteredSearches,
groupedSearches,
togglePin,
deleteEntry,
searchHistory,
} = useSearchHistory({ limit: 100, enableGrouping: true });
```
Restoring a past search (re-running its query) is handled separately by `useHistoryRestore`, used in `SearchForm`:
```typescript
const { restoreSearch } = useHistoryRestore(updateQuery, textAreaRef);
```
**Features:**
- Fuzzy search through history
- Date-based grouping (Today, Yesterday, Last Week, etc.)
- Pin/unpin searches
- Restore previous search (re-runs query)
- Analytics: Search frequency, cache hit rate
## State Flow Examples
### Search Flow
```
User types query
↓
SearchForm updates queryPubSub
↓
User submits
↓
searchAndRespond() called
↓
searchText() updates textSearchStatePubSub → 'loading'
↓
SearchResultsSection shows loading skeleton
↓
API returns results
↓
textSearchResultsPubSub updated with results
↓
textSearchStatePubSub → 'idle'
↓
SearchResultsSection renders results
```
### AI Response Flow
```
Search results ready
↓
canStartResponding() → true
↓
textGenerationStatePubSub → 'loadingModel'
↓
AiResponseSection shows "Loading AI model..."
↓
Model loaded
↓
textGenerationStatePubSub → 'generating'
↓
Response tokens stream in
↓
responsePubSub updated (throttled 12/sec)
↓
AiResponseSection updates content
↓
Generation complete
↓
textGenerationStatePubSub → 'completed'
```
### Chat Flow
```
User sends message
↓
Message added to chatMessagesPubSub
↓
generateChatResponse() called
↓
Token budget calculated
↓
If overflow: generate summary → conversationSummaryPubSub
↓
Inference API called
↓
Response tokens stream to responsePubSub
↓
Full response added to chatMessagesPubSub
↓
Saved to IndexedDB
```
## Custom Hooks
### usePubSub
Subscribes to a PubSub channel:
```typescript
const [value, setValue] = usePubSub(channel);
```
### useSearchHistory
Manages search history from IndexedDB:
```typescript
const { searchHistory, groupedSearches, deleteEntry, togglePin } = useSearchHistory();
```
### useDrawerState
Manages open/close state for a drawer with log entry tracking:
```typescript
const { isDrawerOpen, openDrawer, closeDrawer } = useDrawerState(
"User opened the menu",
"User closed the menu",
);
```
## Styling
**Framework:** Mantine UI v9
**Theme Configuration:**
```typescript
// client/components/App/App.tsx
```
**Dark Mode:**
- Default color scheme is dark
- All components support dark mode via Mantine
## Accessibility
**Standards:** WCAG 2.1 AA compliance
**Features:**
- All interactive elements keyboard accessible
- ARIA labels on select interactive elements (e.g. chat input, message list, history actions, logs modal)
- Focus management in drawers and modals
- Screen reader announcements for loading states
**Implementation:**
```typescript
// Using Mantine's accessibility props
Enter keywords to search
```
## Component Design Principles
1. **Single Responsibility:** Components do one thing well
2. **PubSub-First:** Use channels for cross-component communication
3. **Lazy Loading:** Route-level components use `React.lazy()` for code splitting
4. **Error Boundaries:** Used selectively -- `SearchResultsSection` wraps each result type, and `MarkdownRenderer` wraps syntax-highlighted code blocks (falling back to plain text on failure). AI response and chat components are not currently wrapped in error boundaries.
## File Organization
Most components are single `.tsx` files directly under their feature directory (e.g. `client/components/AiResponse/ChatInterface.tsx`). There is no `index.tsx` re-export convention. CSS Modules are used only where needed (currently just `ImageResultsList.module.css`). Tests, when present, are co-located as `ComponentName.test.tsx` alongside the component, but not every component has one.
## Related Topics
- **Search Module**: `docs/search-history.md` - History implementation
- **AI Integration**: `docs/ai-integration.md` - Text generation flow
- **State Management**: `docs/overview.md` - PubSub architecture
- **Design**: `docs/design.md` - UI/UX principles