AI Chat Interface#
Sure's AI chat interface provides a conversational assistant embedded in the main app layout. It operates in two distinct modes depending on viewport: a desktop right sidebar that slides in/out alongside the main content, and a dedicated full-screen mobile view accessible via the bottom navigation bar. All chat interactions go through the same underlying models and background jobs regardless of where they are triggered.
Related systems (out of scope here):
- LLM Tool Calling — how the AI invokes application functions (e.g.,
create_category) - AI Bank Statement Extraction — document-specific parsing pipeline
Layout Integration (Desktop vs. Mobile)#
Chat is wired into app/views/layouts/application.html.erb at the outermost shell level.
Desktop: A collapsible right sidebar is shown or hidden based on Current.user.show_ai_sidebar?. It lazy-loads the active chat via a turbo_frame_tag pointing at chat_view_path. The sidebar is guarded by a consent overlay: when Current.user.ai_enabled? is false, a blurred _ai_consent partial covers the chat area .
Mobile: The bottom nav includes an icon-assistant tab that links to chats_path . This is marked mobile_only: true so it's excluded from the 84px icon-based desktop left nav . In "intro" UI layout mode, the mobile nav is replaced with simplified Home + Intro tabs .
The Turbo frame identifier for the chat sidebar is the symbol :sidebar_chat, returned by the chat_frame helper. The chat_view_path helper resolves the correct URL for the frame based on the current chat state and params[:chat_view] .
Consent Management#
Before a user can interact with the AI, they must opt in. The consent gate has two layers:
ai_available?— infrastructure check. On self-hosted deployments, it verifies that at least one LLM provider (OpenAI or Anthropic) has valid credentials, or that an external assistant is configured. On cloud, it returnstrueunconditionally.ai_enabled?— requires bothai_available?and the user'sai_enabledboolean to betrue.
The _ai_consent partial presents different copy depending on availability:
- If
ai_available?→ shows an opt-in form that PATCHesuser[ai_enabled]=truetouser_path. - If not available → shows an "unavailable" message with no action button .
A disable note is always shown below (t(".disable_note")), indicating the setting can be turned off later .
Chat Views and Controller#
Routes: Standard REST resource. Key paths: GET /chats (index), GET /chats/:id (show), POST /chats (create), POST /chats/:id/retry (retry).
ChatsController :
index— lists the user's chats in descending creation order; explicitly sets@chat = nilto override the application-level "last viewed chat" default.show— loads the chat and callsset_last_viewed_chat, persisting it as the user'slast_viewed_chat.create— callsChat.start!with the prompt and AI model, then redirects tochat_pathwiththinking: true.retry— callschat.retry_last_message!and redirects back to the chat.
Index view : If no chats exist, shows an ai_greeting partial and the messages/chat_form inline. If chats exist, renders a list with a new_chat_path link.
Show view : Subscribes to real-time updates via turbo_stream_from @chat. Renders ordered conversation_messages (or the ai_greeting if empty), an error partial if needed, and a sticky chat form at the bottom.
Message Lifecycle#
The Message model uses Rails STI with two concrete types:
| Type | Role | Key behavior |
|---|---|---|
UserMessage | "user" | On after_create_commit, triggers chat.ask_assistant_later(self) |
AssistantMessage | "assistant" | Has append_text! for streaming; status starts pending then transitions to complete or failed |
Status states: pending → complete / failed . A pending assistant message renders as "Thinking…" in the UI until content arrives via broadcast.
Async flow:
UserMessageis created →after_create_commitcallsask_assistant_later, which creates a blankpendingAssistantMessageand enqueuesAssistantResponseJobon thehigh_priorityqueue.- The job calls
message.request_response(...)→chat.ask_assistant(message)→assistant.respond_to(message). Assistant::Responderstreams chunks back; each text chunk callsappend_text!on the pending message, triggeringafter_update_commitbroadcasts to all subscribers.- On failure,
chat.add_errorstores a JSONB error payload and broadcasts the error partial.
Undelivered response watchdog: If a worker dies before completing a response, handle_undelivered_response! can be called by the client. It re-reads the row under a lock, and only acts if the message is still pending and has aged past UNDELIVERED_RESPONSE_TIMEOUT (60 seconds server-side) . Partially-streamed content is demoted to failed rather than deleted.
Error classification: Chat#classify_error_message pattern-matches the raw error string against rate-limit, 5xx, and auth patterns to return a user-friendly i18n key .
Key Files#
| File | Purpose |
|---|---|
app/views/layouts/application.html.erb | Desktop sidebar + mobile nav wiring |
app/views/chats/index.html.erb | Chat list / empty-state view |
app/views/chats/show.html.erb | Active chat view with turbo_stream_from |
app/views/chats/_ai_consent.html.erb | Consent opt-in/unavailable overlay |
app/controllers/chats_controller.rb | CRUD + retry actions |
app/helpers/chats_helper.rb | chat_frame + chat_view_path helpers |
app/models/chat.rb | Chat record, start!, error handling, ask_assistant_later |
app/models/user_message.rb | Triggers async response on creation |
app/jobs/assistant_response_job.rb | High-priority job that calls request_response |
app/models/user.rb | ai_enabled?, ai_available?, show_ai_sidebar? |