Spaces:
Running
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).
// 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
Providerwrapper needed; any module imports a channel frompubSub.ts - Selective subscriptions β Components subscribe only to channels they use; a streaming token update throttling
responsePubSubdoes not trigger re-renders in unrelated components - Persistence as a decorator β
createLocalStoragePubSublayers 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:
- Hydration β On first call, reads existing value from
localStoragevialocalStorage.getItem(key). If a stored JSON string is found, it is parsed and used as the initial value; otherwise the default value is used - Persistence β A subscriber is immediately registered on the inner PubSub that calls
localStorage.setItemwith 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:
// App.tsx
// Access key validation is handled via useAccessKeyValidation hook
// which checks localStorage for a stored key hash and verifies it server-side
// Renders <AccessPage /> if validation fails, <MainPage /> 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), triggerssearchAndRespond()(on submit)
Logic:
function SearchForm() {
const [query, setQuery] = usePubSub(queryPubSub);
const [searchState] = usePubSub(textSearchStatePubSub);
const handleSubmit = () => {
searchAndRespond();
};
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button disabled={searchState === 'running'}>
{searchState === 'running' ? 'Searching...' : 'Search'}
</button>
</form>
);
}
SearchResultsSection (client/components/Search/Results/)
Responsibility: Display search results (text and images)
PubSub:
- Subscribes:
textSearchResultsPubSub,imageSearchResultsPubSub,textSearchStatePubSub
Logic:
function SearchResultsSection() {
const [textResults] = usePubSub(textSearchResultsPubSub);
const [imageResults] = usePubSub(imageSearchResultsPubSub);
const [searchState] = usePubSub(textSearchStatePubSub);
if (searchState === 'running') return <LoadingSkeleton />;
if (searchState === 'failed') return <ErrorMessage />;
return (
<>
<SearchResultsList searchResults={textResults} />
{settings.enableImageSearch && <ImageResultsList searchResults={imageResults} />}
</>
);
}
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:
function AiResponseSection() {
const [response] = usePubSub(responsePubSub);
const [textGenerationState] = usePubSub(textGenerationStatePubSub);
const [chatMessages] = usePubSub(chatMessagesPubSub);
if (["generating", "interrupted", "completed", "failed"].includes(textGenerationState)) {
return (
<>
<AiResponseContent textGenerationState={textGenerationState} response={response} />
{textGenerationState === "completed" && (
<ChatInterface initialResponse={response} initialMessages={chatMessages} />
)}
</>
);
}
if (textGenerationState === "loadingModel") return <LoadingModelContent />;
if (["preparingToGenerate", "awaitingSearchResults"].includes(textGenerationState)) {
return <PreparingContent textGenerationState={textGenerationState} />;
}
if (textGenerationState === "awaitingModelDownloadAllowance") {
return <AiModelDownloadAllowanceContent />;
}
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:
// 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()
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:
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:
const [value, setValue] = usePubSub(channel);
useSearchHistory
Manages search history from IndexedDB:
const { searchHistory, groupedSearches, deleteEntry, togglePin } = useSearchHistory();
useDrawerState
Manages open/close state for a drawer with log entry tracking:
const { isDrawerOpen, openDrawer, closeDrawer } = useDrawerState(
"User opened the menu",
"User closed the menu",
);
Styling
Framework: Mantine UI v9
Theme Configuration:
// client/components/App/App.tsx
<MantineProvider defaultColorScheme="dark">
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:
// Using Mantine's accessibility props
<Button aria-label="Search the web">
<SearchIcon />
</Button>
<TextInput
label="Search query"
aria-describedby="search-help"
/>
<span id="search-help">Enter keywords to search</span>
Component Design Principles
- Single Responsibility: Components do one thing well
- PubSub-First: Use channels for cross-component communication
- Lazy Loading: Route-level components use
React.lazy()for code splitting - Error Boundaries: Used selectively --
SearchResultsSectionwraps each result type, andMarkdownRendererwraps 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