MiniSearch / docs /ui-components.md
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
6ace587
|
Raw
History Blame Contribute Delete
16.9 kB

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 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:

// 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), triggers searchAndRespond() (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

  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