Spaces:
Paused
Download docs/ui-components.md from Nymbo/MiniSearch: direct link, hf CLI and curl.
- Browser
- Download file 20.5 kB
-
https://huggingface.co/spaces/Nymbo/MiniSearch/resolve/main/docs/ui-components.md
- Command line
-
hf download hf://spaces/Nymbo/MiniSearch/docs/ui-components.md
-
curl -L -o ui-components.md https://huggingface.co/spaces/Nymbo/MiniSearch/resolve/main/docs/ui-components.md
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 |
textToSpeechStatePubSub |
TextToSpeechState |
Whether an answer is being read aloud: "idle" | "speaking" |
AiResponseContent |
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 |
textSearchStalePubSub |
boolean |
Whether the text results on screen came from the cache because the live search failed | TextSearchResults |
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. Each flag has one owner: the send and regenerate handlers write isGeneratingResponse, regenerateFollowUpQuestion writes isGeneratingFollowUpQuestion, and every writer merges getChatGenerationState() so a concurrent flow is not clobbered. A boolean cannot represent two follow-up calls at once, so each carries an invocation token, and a completion that is no longer the current one writes nothing. Unmounting is the exception to the merge: it replaces both flags outright, bumps the token, and stops the response writers touching the channel at all, so an orphaned flow cannot write into the instance that replaced it |
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
β βββ DictationButton (floats inside the query field)
β βββ 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
βββ DictationButton (floats inside the follow-up field)
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)
The query field is wrapped in its own position: relative element so
DictationButton anchors to the field rather than to the form, and stays put
when the row of buttons below the field changes height.
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>
);
}
DictationButton (client/components/DictationButton.tsx)
Responsibility: Fills a text field from the microphone
Shared by SearchForm and ChatInputArea, which is why it sits at the top of
client/components/ rather than under one feature folder. It owns the whole
dictation session: the engine choice, the microphone lifecycle, and the splice
that appends each transcript without clobbering what the user typed meanwhile.
PubSub:
- Subscribes:
settingsPubSub(enableDictation,enableLocalDictationModel)
Props:
| Prop | Role |
|---|---|
getValue / setValue |
Reads and replaces the field's content. The caller owns the field, so the button never touches the DOM itself |
labelScope |
Names the field in the accessible labels, as the object of "Dictate" and "Stop dictating": "the search query", "a follow-up question" |
rightOffset |
Distance from the right edge of the positioned ancestor, for a field that already has a button at its edge |
disabled |
Blocks a new session and stops one already running, for a field that has gone read-only |
The button renders itself absolutely positioned, so each caller wraps its field
in a position: relative element and reserves dictationButtonWidth of right
padding on the input. It renders nothing when enableDictation is off or no
engine can run, which is also why the setting and the disabled prop each stop a
session of their own accord: returning null is not an unmount, so the cleanup
effect never runs.
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: Dictation toggle, on-device dictation model toggle, text-to-speech engine and 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",
);
useScreenWakeLock
Holds the Screen Wake Lock while the flag is true, so a phone left untouched during a long answer does not lock while the text streams in:
useScreenWakeLock(statesKeepingTheScreenAwake.includes(textGenerationState));
The lock is requested by AiResponseSection for the initial answer and by
ChatInterface for follow-up turns and for the follow-up question that follows
them, and dropped as soon as generation ends. Browsers without the API get no
lock and no error.
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); a component shared by two feature directories sits at the top of client/components/ instead (currently just DictationButton.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