Consolidate Stimulus controllers, thinking state, controllers, and views

This commit is contained in:
Zach Gollwitzer
2025-03-14 17:36:17 -04:00
parent 216f8a42bf
commit 474260f213
34 changed files with 308 additions and 680 deletions

View File

@@ -316,8 +316,8 @@
}
@layer base {
form>button {
@apply cursor-pointer;
button {
@apply cursor-pointer focus-visible:outline-gray-900;
}
hr {

View File

@@ -11,13 +11,6 @@ class ChatsController < ApplicationController
def show
set_last_viewed_chat(@chat)
@messages = @chat.messages.conversation.ordered
@message = Message.new
respond_to do |format|
format.html
format.turbo_stream
end
end
def new
@@ -25,40 +18,15 @@ class ChatsController < ApplicationController
end
def create
@chat = Current.user.chats.new(chat_params.merge(title: "New Chat"))
@chat = Current.user.chats.create_from_message!(chat_params[:content])
@message = @chat.messages.user.first
if @chat.save
set_last_viewed_chat(@chat)
set_last_viewed_chat(@chat)
# Create initial system message with enhanced financial assistant context
@chat.messages.create(
content: "You are a helpful financial assistant for Maybe. You can answer questions about the user's finances including net worth, account balances, income, expenses, spending patterns, budgets, and financial goals. You have access to the user's financial data and can provide insights based on their transactions and accounts. Be conversational, helpful, and provide specific financial insights tailored to the user's question.",
role: "developer",
)
# TODO: Enable again
# ProcessAiResponseJob.perform_later(@message)
# Create user message if content is provided
if params[:content].present?
@user_message = @chat.messages.create(
content: params[:content],
role: "user",
)
end
# Process AI response if user message was created
if defined?(@user_message) && @user_message.persisted?
ProcessAiResponseJob.perform_later(@chat.id, @user_message.id)
end
respond_to do |format|
format.html { redirect_to root_path(chat_id: @chat.id) }
format.turbo_stream { redirect_to root_path(chat_id: @chat.id, format: :html) }
end
else
respond_to do |format|
format.html { redirect_to chats_path, alert: "Failed to create chat" }
format.turbo_stream { render turbo_stream: turbo_stream.replace("chat_form", partial: "chats/form", locals: { chat: @chat }) }
end
end
redirect_to chat_path(@chat, thinking: true)
end
def edit
@@ -76,6 +44,7 @@ class ChatsController < ApplicationController
def destroy
@chat.destroy
clear_last_viewed_chat
redirect_to chats_path, notice: "Chat was successfully deleted"
end

View File

@@ -3,28 +3,18 @@ class MessagesController < ApplicationController
before_action :ensure_ai_enabled
def create
@message = @chat.messages.new(message_params)
@message.user = Current.user
@message.role = "user"
@message = @chat.messages.create!(message_params)
if @message.save
respond_to do |format|
format.html { redirect_to root_path(chat_id: @chat.id) }
format.turbo_stream
end
# TODO: Enable again
# ProcessAiResponseJob.perform_later(@message)
# Process AI response in background
ProcessAiResponseJob.perform_later(@chat.id, @message.id)
else
respond_to do |format|
format.html { redirect_to root_path(chat_id: @chat.id), alert: "Failed to send message" }
format.turbo_stream { render turbo_stream: turbo_stream.replace("message_form", partial: "messages/form", locals: { chat: @chat, message: @message }) }
end
respond_to do |format|
format.html { redirect_to chat_path(@chat) }
format.turbo_stream
end
end
private
def set_chat
@chat = Current.user.chats.find(params[:chat_id])
end

View File

@@ -154,13 +154,6 @@ module ApplicationHelper
markdown.render(text).html_safe
end
def chat_view_path(chat)
return new_chat_path if params[:chat_view] == "new"
return chats_path if chat.nil? || params[:chat_view] == "all"
chat.persisted? ? chat_path(chat) : new_chat_path
end
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }

View File

@@ -0,0 +1,12 @@
module ChatsHelper
def chat_frame
:sidebar_chat
end
def chat_view_path(chat)
return new_chat_path if params[:chat_view] == "new"
return chats_path if chat.nil? || params[:chat_view] == "all"
chat.persisted? ? chat_path(chat) : new_chat_path
end
end

View File

@@ -13,6 +13,13 @@ module MenusHelper
end
end
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
concat(tag.span(label, class: "text-sm"))
end
end
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
button_to url,
method: :delete,

View File

@@ -1,96 +1,61 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["messages", "form", "input"]
static targets = ["messages", "form", "input"];
connect() {
this.scrollToBottom()
this.setupAutoResize()
this.setupMessageObserver()
}
scrollToBottom() {
if (this.hasMessagesTarget) {
const messagesContainer = this.messagesTarget.closest('#chat-container')
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight
}
}
}
setupAutoResize() {
if (this.hasInputTarget) {
this.inputTarget.addEventListener('input', this.autoResize.bind(this))
// Initialize height
this.autoResize()
}
}
setupMessageObserver() {
if (this.hasMessagesTarget) {
// Create a mutation observer to watch for new messages
this.observer = new MutationObserver((mutations) => {
let shouldScroll = false
mutations.forEach((mutation) => {
if (mutation.addedNodes.length) {
shouldScroll = true
}
})
if (shouldScroll) {
// Use setTimeout to ensure DOM is fully updated before scrolling
setTimeout(() => this.scrollToBottom(), 0)
}
})
// Start observing
this.observer.observe(this.messagesTarget, {
childList: true,
subtree: true
})
}
this.#configureAutoScroll();
}
disconnect() {
// Clean up observer when controller is disconnected
if (this.observer) {
this.observer.disconnect()
if (this.messagesObserver) {
this.messagesObserver.disconnect();
}
}
autoResize() {
const input = this.inputTarget
// Reset height to calculate proper scrollHeight
input.style.height = 'auto'
// Set new height based on content
input.style.height = `${input.scrollHeight}px`
// Cap at 150px max height
if (input.scrollHeight > 150) {
input.style.height = '150px'
input.style.overflowY = 'auto'
} else {
input.style.overflowY = 'hidden'
const input = this.inputTarget;
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
const maxLines = 3; // 3 lines = 60px total
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
input.style.overflowY =
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
}
submitSampleQuestion(e) {
this.inputTarget.value = e.target.dataset.chatQuestionParam;
setTimeout(() => {
this.formTarget.requestSubmit();
}, 200);
}
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
handleInputKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.formTarget.requestSubmit();
}
}
submit(event) {
// Let the form submit normally, but prepare for the response
this.startLoadingState()
#configureAutoScroll() {
this.messagesObserver = new MutationObserver((_mutations) => {
if (this.hasMessagesTarget) {
this.#scrollToBottom();
}
});
// Listen to entire sidebar for changes, always try to scroll to the bottom
this.messagesObserver.observe(this.element, {
childList: true,
subtree: true,
});
}
startLoadingState() {
if (this.hasFormTarget) {
this.formTarget.classList.add('opacity-50')
this.formTarget.querySelector('button[type="submit"]').disabled = true
}
}
endLoadingState() {
if (this.hasFormTarget) {
this.formTarget.classList.remove('opacity-50')
this.formTarget.querySelector('button[type="submit"]').disabled = false
this.formTarget.reset()
this.autoResize()
}
}
}
#scrollToBottom = () => {
console.log("scrolling to bottom");
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
};
}

View File

@@ -1,32 +0,0 @@
import { Controller } from "@hotwired/stimulus";
/**
* A controller to toggle between chat list and chat content in the sidebar
*/
export default class extends Controller {
static targets = ["button", "content", "defaultContent", "menuIcon", "backIcon", "header", "listHeader"];
connect() {
this.isShowingChatList = false;
}
toggle() {
this.isShowingChatList = !this.isShowingChatList;
if (this.isShowingChatList) {
this.contentTarget.classList.remove("hidden");
this.defaultContentTarget.classList.add("hidden");
this.menuIconTarget.classList.add("hidden");
this.backIconTarget.classList.remove("hidden");
this.headerTarget.classList.add("hidden");
this.listHeaderTarget.classList.remove("hidden");
} else {
this.contentTarget.classList.add("hidden");
this.defaultContentTarget.classList.remove("hidden");
this.menuIconTarget.classList.remove("hidden");
this.backIconTarget.classList.add("hidden");
this.headerTarget.classList.remove("hidden");
this.listHeaderTarget.classList.add("hidden");
}
}
}

View File

@@ -1,87 +0,0 @@
import { Controller } from "@hotwired/stimulus"
/**
* A controller to handle AI progress updates in the chat interface
*/
export default class extends Controller {
static targets = ["thinking"]
connect() {
console.log("ChatProgressController connected")
this.setupProgressObserver()
// Check if the thinking indicator is already visible
if (this.hasThinkingTarget && !this.thinkingTarget.classList.contains('hidden')) {
console.log("Thinking indicator is already visible on connect")
this.scrollToBottom()
}
}
setupProgressObserver() {
if (this.hasThinkingTarget) {
console.log("Setting up progress observer for thinking target")
// Create a mutation observer to watch for changes to the thinking indicator
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
this.handleThinkingVisibilityChange()
} else if (mutation.type === 'childList') {
this.handleThinkingContentChange()
}
})
})
// Start observing
this.observer.observe(this.thinkingTarget, {
attributes: true,
childList: true,
subtree: true
})
} else {
console.warn("No thinking target found")
// Try to find the thinking element by ID as a fallback
const thinkingElement = document.getElementById('thinking')
if (thinkingElement) {
console.log("Found thinking element by ID")
this.thinkingTarget = thinkingElement
this.setupProgressObserver()
}
}
}
handleThinkingVisibilityChange() {
const isHidden = this.thinkingTarget.classList.contains('hidden')
console.log("Thinking visibility changed:", isHidden ? "hidden" : "visible")
if (!isHidden) {
// Scroll to the bottom when thinking indicator becomes visible
this.scrollToBottom()
// Force a redraw to ensure the indicator is visible
void this.thinkingTarget.offsetHeight
}
}
handleThinkingContentChange() {
console.log("Thinking content changed")
// Scroll to the bottom when thinking indicator content changes
this.scrollToBottom()
}
scrollToBottom() {
const messagesContainer = document.querySelector("[data-chat-scroll-target='messages']")
if (messagesContainer) {
setTimeout(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight
}, 100)
}
}
disconnect() {
if (this.observer) {
this.observer.disconnect()
}
}
}

View File

@@ -1,121 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "messages"]
connect() {
console.log("ChatScrollController connected")
this.scrollToBottom()
this.setupMessageObserver()
this.setupThinkingObserver()
// Add event listener for manual scrolling (to detect if user has scrolled up)
if (this.hasContainerTarget) {
this.containerTarget.addEventListener('scroll', this.handleScroll.bind(this))
}
// Add resize observer to handle container resizing
this.setupResizeObserver()
// Set initial userHasScrolled state
this.userHasScrolled = false
}
disconnect() {
if (this.messageObserver) {
this.messageObserver.disconnect()
}
if (this.thinkingObserver) {
this.thinkingObserver.disconnect()
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
if (this.hasContainerTarget) {
this.containerTarget.removeEventListener('scroll', this.handleScroll.bind(this))
}
}
scrollToBottom() {
console.log("Scrolling to bottom")
if (this.hasContainerTarget) {
this.containerTarget.scrollTop = this.containerTarget.scrollHeight
}
}
handleScroll() {
if (this.hasContainerTarget) {
const container = this.containerTarget
const isScrolledToBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50
// Update userHasScrolled state based on scroll position
this.userHasScrolled = !isScrolledToBottom
console.log("User has scrolled:", this.userHasScrolled)
}
}
setupResizeObserver() {
if (this.hasContainerTarget) {
this.resizeObserver = new ResizeObserver(() => {
if (!this.userHasScrolled) {
this.scrollToBottom()
}
})
this.resizeObserver.observe(this.containerTarget)
}
}
setupMessageObserver() {
if (this.hasMessagesTarget) {
console.log("Setting up message observer")
// Create a mutation observer to watch for new messages
this.messageObserver = new MutationObserver((mutations) => {
let shouldScroll = false
mutations.forEach((mutation) => {
if (mutation.addedNodes.length) {
shouldScroll = true
}
})
if (shouldScroll && !this.userHasScrolled) {
// Use setTimeout to ensure DOM is fully updated before scrolling
setTimeout(() => this.scrollToBottom(), 0)
}
})
// Start observing
this.messageObserver.observe(this.messagesTarget, {
childList: true,
subtree: true
})
}
}
setupThinkingObserver() {
// Watch for changes to the thinking indicator
const thinkingElement = document.getElementById('thinking')
if (thinkingElement) {
console.log("Setting up thinking observer")
this.thinkingObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const isHidden = thinkingElement.classList.contains('hidden')
console.log("Thinking visibility changed:", isHidden ? "hidden" : "visible")
if (!isHidden && !this.userHasScrolled) {
// Scroll to bottom when thinking indicator becomes visible
setTimeout(() => this.scrollToBottom(), 0)
}
}
})
})
// Start observing
this.thinkingObserver.observe(thinkingElement, {
attributes: true
})
}
}
}

View File

@@ -1,21 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="example-question"
export default class extends Controller {
static values = { text: String }
fillForm(event) {
event.preventDefault()
// Find the textarea in the form
const textarea = document.querySelector("textarea[name='message[content]']")
if (!textarea) return
// Set the value and focus
textarea.value = this.textValue
textarea.focus()
// Trigger input event to resize the textarea if using autogrow
textarea.dispatchEvent(new Event('input'))
}
}

View File

@@ -1,59 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["submit"]
connect() {
console.log("MessageFormController connected")
this.thinkingElement = document.getElementById("thinking")
console.log("Thinking element:", this.thinkingElement)
}
reset() {
console.log("MessageFormController reset called")
this.element.reset()
this.element.querySelector("textarea").style.height = "auto"
// We don't hide the thinking indicator here anymore
// It will be hidden by the ProcessAiResponseJob when the AI response is ready
}
checkSubmit(event) {
console.log("MessageFormController checkSubmit called", event.key)
// Submit the form when Enter is pressed without Shift
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
this.submitForm()
}
}
submitForm() {
console.log("MessageFormController submitForm called")
// Get the form action to determine if we're creating a new chat or adding to existing
const isNewChat = this.element.action.includes('/chats') && !this.element.action.includes('/messages');
console.log("Is new chat:", isNewChat);
// Show the thinking indicator
if (this.thinkingElement) {
console.log("Showing thinking indicator")
this.thinkingElement.classList.remove("hidden")
// Force a redraw to ensure the indicator is visible
void this.thinkingElement.offsetHeight;
// Scroll to the bottom of the chat to show the thinking indicator
const chatMessages = document.querySelector("[data-chat-scroll-target='messages']")
if (chatMessages) {
setTimeout(() => {
chatMessages.scrollTop = chatMessages.scrollHeight
}, 100)
}
} else {
console.warn("Thinking element not found")
}
console.log("Submit target:", this.submitTarget)
this.element.requestSubmit(this.submitTarget)
}
}

View File

@@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
userId: String,
side: { type: String, default: "left" } // "left" or "right"
side: { type: String, default: "left" }, // "left" or "right"
};
static targets = ["leftPanel", "rightPanel", "content"];
@@ -13,7 +13,8 @@ export default class extends Controller {
const side = event.currentTarget.dataset.side || this.sideValue;
// Get the appropriate panel based on the side
const panel = side === "left" ? this.leftPanelTarget : this.rightPanelTarget;
const panel =
side === "left" ? this.leftPanelTarget : this.rightPanelTarget;
// Toggle the sidebar visibility
if (side === "left") {
@@ -25,7 +26,7 @@ export default class extends Controller {
// For right panel, use the correct width class
panel.classList.toggle("w-0");
panel.classList.toggle("opacity-0");
panel.classList.toggle("w-[375px]");
panel.classList.toggle("w-[400px]");
panel.classList.toggle("opacity-100");
}
@@ -37,7 +38,10 @@ export default class extends Controller {
this.adjustContentWidth(leftSidebarOpen, rightSidebarOpen);
// Save user preference
this.saveUserPreference(side, side === "left" ? leftSidebarOpen : rightSidebarOpen);
this.saveUserPreference(
side,
side === "left" ? leftSidebarOpen : rightSidebarOpen,
);
}
adjustContentWidth(leftSidebarOpen, rightSidebarOpen) {
@@ -55,7 +59,8 @@ export default class extends Controller {
}
saveUserPreference(side, isOpen) {
const preferenceField = side === "left" ? "show_sidebar" : "show_ai_sidebar";
const preferenceField =
side === "left" ? "show_sidebar" : "show_ai_sidebar";
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",

View File

@@ -1,24 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
}
resize() {
const textarea = this.element
// Reset height to auto to get the correct scrollHeight
textarea.style.height = "auto"
// Set the height to match the content (with a max height)
const newHeight = Math.min(textarea.scrollHeight, 150)
textarea.style.height = `${newHeight}px`
// Scroll to the bottom of the chat when the textarea grows
const chatMessages = document.getElementById("chat-messages")
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight
}
}
}

View File

@@ -1,9 +1,9 @@
class ProcessAiResponseJob < ApplicationJob
queue_as :default
def perform(chat_id, message_id)
chat = Chat.find(chat_id)
user_message = Message.find(message_id)
def perform(message)
chat = message.chat
user_message = message
# Debug mode: Log the start of processing
Ai::DebugMode.log_to_chat(chat, "🐞 DEBUG: Starting to process user query")

View File

@@ -1,10 +1,42 @@
class Chat < ApplicationRecord
belongs_to :user
has_one :viewer, class_name: "User", foreign_key: :current_chat_id, dependent: :nullify # "Last chat user has viewed"
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
has_many :messages, dependent: :destroy
validates :title, presence: true
scope :ordered, -> { order(created_at: :desc) }
class << self
def create_from_message!(user_message)
new(
title: user_message.first(20),
messages: [
Message.new(role: "developer", content: developer_prompt),
Message.new(role: "user", content: user_message)
]
)
end
def developer_prompt
<<~PROMPT
You are a helpful financial assistant for Maybe, a personal finance app.
You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.
When users ask financial questions:
1. Use the provided functions to retrieve the relevant data
2. Provide ONLY the most important numbers and insights
3. Eliminate all unnecessary words and context
4. Use simple markdown for formatting
5. Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.
DO NOT:
- Add introductions or conclusions
- Apologize or explain limitations
Present monetary values using the format provided by the functions.
PROMPT
end
end
end

View File

@@ -17,7 +17,7 @@ class Message < ApplicationRecord
debug: "debug" # internal only, never sent to OpenAI
}
validates :content, presence: true, allow_blank: true
validates :content, presence: true
after_create_commit :broadcast_and_fetch
after_update_commit -> { broadcast_update_to chat }

View File

@@ -1,3 +1,3 @@
<div class="w-18 h-18 flex-shrink-0 -mr-1 -ml-2 -mt-1">
<div class="w-16 h-16 flex-shrink-0 -ml-3 -mt-3">
<%= image_tag "ai.svg", alt: "AI", class: "w-full h-full" %>
</div>
</div>

View File

@@ -1,28 +1,39 @@
<div class="flex items-start gap-1 mt-4 w-full">
<div class="flex items-start gap-2 w-full">
<%= render "chats/ai_avatar" %>
<div class="pt-2 pr-1 max-w-[85%] text-gray-800 text-sm">
<div class="max-w-[85%] text-sm space-y-4 text-primary">
<p>Hey <%= Current.user&.first_name || 'there' %>! I'm an AI built by Maybe to help with your finances. I have access to the web and your account data.</p>
<div class="mt-4 text-gray-600">
<p>
You can use <span class="bg-white border border-gray-200 px-1.5 py-0.5 rounded font-mono text-xs">/</span> to access commands
</div>
</p>
<div class="mt-4">
<p class="text-gray-600 mb-3">Here's a few questions you can ask:</p>
<div class="space-y-3">
<p>Here's a few questions you can ask:</p>
<div class="space-y-2">
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("bar-chart-2") %> Evaluate investment portfolio
</button>
<% questions = [
{
icon: "bar-chart-2",
text: "Evaluate investment portfolio"
},
{
icon: "credit-card",
text: "Show spending insights"
},
{
icon: "alert-triangle",
text: "Find unusual patterns"
}
] %>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("credit-card") %> Show spending insights
</button>
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
<%= icon("alert-triangle") %> Find unusual patterns
</button>
<div class="space-y-2.5">
<% questions.each do |question| %>
<button data-action="chat#submitSampleQuestion"
data-chat-question-param="<%= question[:text] %>"
class="w-full flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100">
<%= icon(question[:icon]) %> <%= question[:text] %>
</button>
<% end %>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %>
<div class="grow">
<%= render "chats/chat_title", chat: chat %>
<%= render "chats/chat_title", chat: chat, ctx: "list" %>
<p class="text-sm text-secondary">
<%= time_ago_in_words(chat.updated_at) %> ago
@@ -10,7 +10,7 @@
</div>
<%= contextual_menu icon: "more-vertical" do %>
<%= contextual_menu_modal_action_item("Edit chat", edit_chat_path(chat), turbo_frame: dom_id(chat, :title)) %>
<%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %>
<%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %>
<% end %>
<% end %>

View File

@@ -4,7 +4,7 @@
<% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>
<div class="flex items-center gap-2 grow">
<%= link_to path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover", data: { turbo_frame: "chat" } do %>
<%= link_to path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
<%= icon("menu", color: "gray" ) %>
<% end %>
@@ -14,10 +14,10 @@
</div>
<%= contextual_menu icon: "more-vertical" do %>
<%= contextual_menu_modal_action_item "Start new chat", new_chat_path, icon: "plus", turbo_frame: "sidebar-chat-content" %>
<%= contextual_menu_item "Start new chat", url: new_chat_path, icon: "plus" %>
<% unless chat.new_record? %>
<%= contextual_menu_modal_action_item "Edit chat title", edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
<%= contextual_menu_item "Edit chat title", url: edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
<%= contextual_menu_destructive_item "Delete chat", chat_path(chat), turbo_confirm: "Are you sure you want to delete this chat?" %>
<% end %>
<% end %>

View File

@@ -4,7 +4,7 @@
<% if chat.new_record? || ctx == "chat" %>
<h3 class="text-sm font-medium text-primary"><%= chat.title || "New chat" %></h3>
<% else %>
<%= link_to chat_path(chat), data: { turbo_frame: "sidebar-chat-content" } do %>
<%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %>
<h3 class="text-sm font-medium text-primary"><%= chat.title %></h3>
<% end %>
<% end %>

View File

@@ -1,5 +0,0 @@
<%= turbo_stream.replace "messages" do %>
<div id="messages" data-turbo-cache="false">
<%= render "chats/ai_greeting", context: 'chat' %>
</div>
<% end %>

View File

@@ -1,27 +1,31 @@
<%= turbo_frame_tag "sidebar-chat-content" do %>
<nav class="mb-6">
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover", data: { turbo_frame: "sidebar-chat-content" } do %>
<%= icon("arrow-left", color: "gray" ) %>
<% end %>
</nav>
<%= turbo_frame_tag chat_frame do %>
<div class="p-4 flex flex-col h-full">
<nav class="mb-6">
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
<%= icon("arrow-left", color: "gray" ) %>
<% end %>
</nav>
<h1 class="text-xl font-medium mb-6">Chats</h1>
<div class="grow">
<h1 class="text-xl font-medium mb-6">Chats</h1>
<div>
<% if @chats.any? %>
<div class="space-y-2">
<%= render @chats %>
</div>
<% else %>
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<%= icon("message-square", size: "lg") %>
<% if @chats.any? %>
<div class="space-y-2 px-0.5">
<%= render @chats %>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-1">No chats yet</h3>
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
<%= link_to "Start a chat", new_chat_path, data: { turbo_frame: "sidebar-chat-content" }, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
</div>
<% end %>
<% else %>
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<%= icon("message-square", size: "lg") %>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-1">No chats yet</h3>
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
<%= link_to "Start a chat", new_chat_path, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
</div>
<% end %>
</div>
<%= render "messages/chat_form" %>
</div>
<% end %>

View File

@@ -1,9 +1,11 @@
<%= turbo_frame_tag "sidebar-chat-content" do %>
<div class="flex flex-col h-full">
<%= turbo_frame_tag chat_frame do %>
<div class="p-4 flex flex-col h-full">
<%= render "chats/chat_nav", chat: @chat %>
<div class="mt-auto py-8">
<%= render "chats/ai_greeting" %>
</div>
<%= render "messages/chat_form", chat: @chat %>
</div>
<% end %>

View File

@@ -1,19 +1,29 @@
<%= turbo_frame_tag "sidebar-chat-content" do %>
<%= turbo_stream_from @chat %>
<%= turbo_frame_tag chat_frame do %>
<div class="flex flex-col h-full">
<%= render "chats/chat_nav", chat: @chat %>
<div class="p-4">
<%= render "chats/chat_nav", chat: @chat %>
</div>
<div id="messages" class="grow flex flex-col gap-8 py-8" data-chat-scroll-target="messages">
<% if @messages.any? %>
<% @messages.each do |message| %>
<%= render "messages/message", message: message %>
<div id="<%= dom_id(@chat, :messages) %>" class="grow py-8 overflow-y-auto" data-chat-target="messages">
<div class="p-4 space-y-6">
<% if @chat.messages.conversation.any? %>
<% @chat.messages.conversation.ordered.each do |message| %>
<%= render "messages/message", message: message %>
<% end %>
<% if params[:thinking] %>
<%= render "messages/thinking_message" %>
<% end %>
<% else %>
<div class="mt-auto">
<%= render "chats/ai_greeting", context: 'chat' %>
</div>
<% end %>
<% else %>
<div class="mt-auto">
<%= render "chats/ai_greeting", context: 'chat' %>
</div>
<% end %>
</div>
</div>
<div class="p-4">
<%= render "messages/chat_form", chat: @chat %>
</div>
</div>
<% end %>

View File

@@ -1,19 +0,0 @@
<%= turbo_stream.update "chat_messages" do %>
<% @messages.each do |message| %>
<div class="flex items-start gap-3 <%= message.user? ? 'justify-end' : '' %> mb-4">
<% unless message.user? %>
<%= render "chats/ai_avatar" %>
<% end %>
<div class="<%= message.user? ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-800' %> rounded-lg p-4 max-w-[85%]">
<p><%= message.content %></p>
</div>
<% if message.user? %>
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0 text-gray-700">
<%= Current.user.initials %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -71,19 +71,16 @@
<% end %>
<%# AI chat sidebar %>
<%= tag.div id: "sidebar-chat-container", class: class_names("flex flex-col p-4 pl-0 justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[375px]" : "w-0"), data: { sidebar_target: "rightPanel", turbo_permanent: true } do %>
<div class="grow overflow-y-auto">
<%= turbo_frame_tag "sidebar-chat-content", src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
<div class="flex justify-center items-center h-full">
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
</div>
<% end %>
</div>
<div class="space-y-2 shrink-0">
<%= render "messages/form" %>
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
</div>
<%= tag.div id: "chat-container",
class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[400px]" : "w-0"),
data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %>
<%# All chat broadcasts are sent to this centralized user-level stream %>
<%= turbo_stream_from Current.user, "chat" %>
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
<div class="flex justify-center items-center h-full">
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
</div>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,32 @@
<%# locals: (chat: nil, message_hint: nil) %>
<div id="chat-form" class="space-y-2">
<% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>
<%= form_with model: model,
class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs",
data: { chat_target: "form" } do |f| %>
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" },
rows: 1 %>
<div class="flex items-center justify-between gap-1">
<div class="flex items-center gap-1">
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
<button type="button" title="Coming soon" class="cursor-not-allowed w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
<%= icon(icon, color: "gray") %>
</button>
<% end %>
</div>
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
<%= icon("arrow-up") %>
</button>
</div>
<% end %>
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
</div>

View File

@@ -0,0 +1,26 @@
<%# locals: (content:) %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 w-full text-gray-700 text-xs font-mono">
<div class="flex items-center mb-1">
<div class="mr-1"><%= icon("terminal", size: 14) %></div>
<div class="font-semibold"><%= content.split("\n").first %></div>
</div>
<% if content.include?("```json") %>
<%
json_start = content.index("```json")
json_end = content.index("```", json_start + 7)
if json_start && json_end
json_content = content[(json_start + 7)...json_end].strip
begin
parsed_json = JSON.parse(json_content)
formatted_json = JSON.pretty_generate(parsed_json)
rescue
formatted_json = json_content
end
end
%>
<div class="mt-2 overflow-x-auto">
<pre class="text-xs"><%= formatted_json %></pre>
</div>
<% end %>
</div>

View File

@@ -1,25 +0,0 @@
<%# locals: (chat: nil) %>
<% model = chat ? [chat, Message.new] : Chat.new %>
<%= form_with model: model, class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs" do |f| %>
<%= f.text_area :content,
placeholder: "Ask anything ...",
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
rows: 1 %>
<div class="flex items-center justify-between gap-1">
<div class="flex items-center gap-1">
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
<button type="button" class="w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
<%= icon(icon, color: "gray") %>
</button>
<% end %>
</div>
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
<%= icon("arrow-up") %>
</button>
</div>
<% end %>

View File

@@ -1,44 +1,18 @@
<%
is_debug_message = message.role == "system" && message.content.start_with?("🐞 DEBUG:")
show_message = (!is_debug_message || ENV["AI_DEBUG_MODE"] == "true")
%>
<%# locals: (message:) %>
<% if show_message %>
<div id="<%= dom_id(message) %>" class="flex items-start gap-3 <%= message.user? ? 'justify-end' : (is_debug_message ? 'justify-start debug-message' : '') %> mb-4 w-full">
<% if is_debug_message %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 w-full text-gray-700 text-xs font-mono">
<div class="flex items-center mb-1">
<div class="mr-1"><%= icon("terminal", size: 14) %></div>
<div class="font-semibold"><%= message.content.split("\n").first %></div>
</div>
<% if message.content.include?("```json") %>
<%
json_start = message.content.index("```json")
json_end = message.content.index("```", json_start + 7)
if json_start && json_end
json_content = message.content[(json_start + 7)...json_end].strip
begin
parsed_json = JSON.parse(json_content)
formatted_json = JSON.pretty_generate(parsed_json)
rescue
formatted_json = json_content
end
end
%>
<div class="mt-2 overflow-x-auto">
<pre class="text-xs"><%= formatted_json %></pre>
</div>
<% end %>
</div>
<% elsif message.user? %>
<div class="bg-gray-100 rounded-lg px-3 py-2 max-w-[85%] text-gray-800">
<p class="whitespace-pre-wrap break-words"><%= message.content %></p>
</div>
<% else %>
<div id="<%= dom_id(message) %>">
<% if message.debug? %>
<%= render "messages/debug_message", content: message.content %>
<% elsif message.user? %>
<div class="ml-auto text-sm text-primary bg-gray-100 rounded-lg px-3 py-2 max-w-[85%]">
<div class="whitespace-pre-wrap break-words"><%= message.content %></div>
</div>
<% elsif message.assistant? %>
<div class="flex items-start">
<%= render "chats/ai_avatar" %>
<div class="pr-2 max-w-[85%] text-gray-800">
<div class="pr-2 max-w-[85%] text-primary">
<div class="prose prose-sm prose-gray break-words"><%= markdown(message.content) %></div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,4 @@
<div id="thinking-message" class="flex items-start">
<%= render "chats/ai_avatar" %>
<p class="text-sm text-secondary animate-pulse">Thinking...</p>
</div>

View File

@@ -1,23 +1,11 @@
<%= turbo_stream.append "messages" do %>
<%= turbo_stream.append dom_id(@chat, :messages) do %>
<%= render "messages/message", message: @message %>
<% end %>
<% if @message.user? %>
<%= turbo_stream.replace "thinking" do %>
<div id="thinking" class="flex items-start gap-3 mb-4">
<%= render "chats/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
</div>
<span class="ml-2 text-gray-600">Thinking...</span>
</div>
</div>
<% end %>
<%= turbo_stream.append dom_id(@chat, :messages) do %>
<%= render "messages/thinking_message" %>
<% end %>
<%= turbo_stream.replace "message_form" do %>
<%= render "messages/form", chat: @chat, message: Message.new, scroll_behavior: true, form_options: { data: { controller: "message-form", action: "turbo:submit-end->chat-scroll#scrollToBottom" } } %>
<% end %>
<% end %>
<%= turbo_stream.replace "chat-form" do %>
<%= render "messages/chat_form", chat: @chat %>
<% end %>