From fa51e9d94846cc738bba8d3eaee1fd2562722885 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 3 Jun 2025 10:40:52 -0400 Subject: [PATCH] REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389) --- .../ai-conversations-sidebar-manager.js | 309 ++++++++++++++++-- .../initializers/ai-conversations-sidebar.js | 307 +---------------- .../modules/ai-bot-conversations/common.scss | 42 --- spec/system/ai_bot/homepage_spec.rb | 15 +- .../page_objects/components/ai_pm_homepage.rb | 6 +- 5 files changed, 295 insertions(+), 384 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index 9e7c45b9..64c4f376 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -1,48 +1,317 @@ import { tracked } from "@glimmer/tracking"; +import { scheduleOnce } from "@ember/runloop"; import Service, { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { TrackedArray } from "@ember-compat/tracked-built-ins"; +import { ajax } from "discourse/lib/ajax"; +import discourseDebounce from "discourse/lib/debounce"; +import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels"; +import { i18n } from "discourse-i18n"; +import AiBotSidebarEmptyState from "../../discourse/components/ai-bot-sidebar-empty-state"; export const AI_CONVERSATIONS_PANEL = "ai-conversations"; +const SCROLL_BUFFER = 100; +const DEBOUNCE = 100; export default class AiConversationsSidebarManager extends Service { @service appEvents; @service sidebarState; + @service messageBus; - @tracked newTopicForceSidebar = false; + @tracked topics = []; + @tracked sections = new TrackedArray(); + @tracked isLoading = true; + + api = null; + isFetching = false; + page = 0; + hasMore = true; + _registered = new Set(); + _hasScrollListener = false; + _scrollElement = null; + _didInit = false; + + _debouncedScrollHandler = () => { + discourseDebounce( + this, + () => { + const element = this._scrollElement; + if (!element) { + return; + } + + const { scrollTop, scrollHeight, clientHeight } = element; + if ( + scrollHeight - scrollTop - clientHeight - SCROLL_BUFFER < 100 && + !this.isFetching && + this.hasMore + ) { + this.fetchMessages(); + } + }, + DEBOUNCE + ); + }; + + constructor() { + super(...arguments); + + this.appEvents.on( + "discourse-ai:bot-pm-created", + this, + this._handleNewBotPM + ); + + this.appEvents.on( + "discourse-ai:conversations-sidebar-updated", + this, + this._attachScrollListener + ); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off( + "discourse-ai:bot-pm-created", + this, + this._handleNewBotPM + ); + this.appEvents.off( + "discourse-ai:conversations-sidebar-updated", + this, + this._attachScrollListener + ); + } forceCustomSidebar() { - // Return early if we already have the correct panel, so we don't - // re-render it. - if (this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL) { - return; - } + document.body.classList.add("has-ai-conversations-sidebar"); + this.sidebarState.isForcingSidebar = true; + // calling this before fetching data + // helps avoid flash of main sidebar mode this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); - // Use separated mode to ensure independence from hamburger menu + this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); this.sidebarState.setSeparatedMode(); - - // Hide panel switching buttons to keep UI clean this.sidebarState.hideSwitchPanelButtons(); - this.sidebarState.isForcingSidebar = true; - document.body.classList.add("has-ai-conversations-sidebar"); - this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); + // don't render sidebar multiple times + if (this._didInit) { + return true; + } + + this._didInit = true; + + this.fetchMessages().then(() => { + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + }); + return true; } + _attachScrollListener() { + const sections = document.querySelector( + ".sidebar-sections.ai-conversations-panel" + ); + this._scrollElement = sections; + + if (this._hasScrollListener || !this._scrollElement) { + return; + } + + sections.addEventListener("scroll", this._debouncedScrollHandler); + + this._hasScrollListener = true; + } + + _removeScrollListener() { + if (this._hasScrollListener) { + this._scrollElement.removeEventListener( + "scroll", + this._debouncedScrollHandler + ); + this._hasScrollListener = false; + this._scrollElement = null; + } + } + stopForcingCustomSidebar() { - // This method is called when leaving your route - // Only restore main panel if we previously forced ours document.body.classList.remove("has-ai-conversations-sidebar"); - const isAdminSidebarActive = - this.sidebarState.currentPanel?.key === ADMIN_PANEL; - // only restore main panel if we previously forced our sidebar - // and not if we are in admin sidebar - if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) { - this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel + + const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL; + if (this.sidebarState.isForcingSidebar && !isAdmin) { + this.sidebarState.setPanel(MAIN_PANEL); this.sidebarState.isForcingSidebar = false; this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar"); } + + this._removeScrollListener(); + } + + async fetchMessages() { + if (this.isFetching || !this.hasMore) { + return; + } + + const isFirstPage = this.page === 0; + this.isFetching = true; + + try { + let { conversations, meta } = await ajax( + "/discourse-ai/ai-bot/conversations.json", + { data: { page: this.page, per_page: 40 } } + ); + + if (isFirstPage) { + this.topics = conversations; + } else { + this.topics = [...this.topics, ...conversations]; + // force rerender when fetching more messages + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + } + + this.page += 1; + this.hasMore = meta.has_more; + + this._rebuildSections(); + } finally { + this.isFetching = false; + this.isLoading = false; + } + } + + _handleNewBotPM(topic) { + this.topics = [topic, ...this.topics]; + this._rebuildSections(); + this._watchForTitleUpdate(topic.id); + } + + _watchForTitleUpdate(topicId) { + if (this._subscribedTopicIds?.has(topicId)) { + return; + } + + this._subscribedTopicIds = this._subscribedTopicIds || new Set(); + this._subscribedTopicIds.add(topicId); + + const channel = `/discourse-ai/ai-bot/topic/${topicId}`; + + this.messageBus.subscribe(channel, (payload) => { + this._applyTitleUpdate(topicId, payload.title); + this.messageBus.unsubscribe(channel); + }); + } + + _applyTitleUpdate(topicId, newTitle) { + this.topics = this.topics.map((t) => + t.id === topicId ? { ...t, title: newTitle } : t + ); + + this._rebuildSections(); + } + + // organize by date and create a section for each date group + _rebuildSections() { + const now = Date.now(); + const fresh = []; + + this.topics.forEach((t) => { + const postedAtMs = new Date(t.last_posted_at || now).valueOf(); + const diffDays = Math.floor((now - postedAtMs) / 86400000); + let dateGroup; + + if (diffDays <= 1) { + dateGroup = "today"; + } else if (diffDays <= 7) { + dateGroup = "last-7-days"; + } else if (diffDays <= 30) { + dateGroup = "last-30-days"; + } else { + const d = new Date(postedAtMs); + const key = `${d.getFullYear()}-${d.getMonth()}`; + dateGroup = key; + } + + let sec = fresh.find((s) => s.name === dateGroup); + if (!sec) { + let title; + switch (dateGroup) { + case "today": + title = i18n("discourse_ai.ai_bot.conversations.today"); + break; + case "last-7-days": + title = i18n("discourse_ai.ai_bot.conversations.last_7_days"); + break; + case "last-30-days": + title = i18n("discourse_ai.ai_bot.conversations.last_30_days"); + break; + default: + title = autoUpdatingRelativeAge(new Date(t.last_posted_at)); + } + sec = { name: dateGroup, title, links: new TrackedArray() }; + fresh.push(sec); + } + + sec.links.push({ + key: t.id, + route: "topic.fromParamsNear", + models: [t.slug, t.id, t.last_read_post_number || 0], + title: t.title, + text: t.title, + classNames: `ai-conversation-${t.id}`, + }); + }); + + this.sections = new TrackedArray(fresh); + + // register each new section once + for (let sec of fresh) { + if (this._registered.has(sec.name)) { + continue; + } + this._registered.add(sec.name); + + this.api.addSidebarSection((BaseCustomSidebarSection) => { + return class extends BaseCustomSidebarSection { + @service("ai-conversations-sidebar-manager") manager; + @service("appEvents") events; + + constructor() { + super(...arguments); + scheduleOnce("afterRender", this, this.triggerEvent); + } + + triggerEvent() { + this.events.trigger("discourse-ai:conversations-sidebar-updated"); + } + + get name() { + return sec.name; + } + + get title() { + return sec.title; + } + + get text() { + return htmlSafe(sec.title); + } + + get links() { + return ( + this.manager.sections.find((s) => s.name === sec.name)?.links || + [] + ); + } + + get emptyStateComponent() { + if (!this.manager.isLoading && this.links.length === 0) { + return AiBotSidebarEmptyState; + } + } + }; + }, AI_CONVERSATIONS_PANEL); + } } } diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index e288f203..de5e7305 100644 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -1,12 +1,4 @@ -import { tracked } from "@glimmer/tracking"; -import { htmlSafe } from "@ember/template"; -import { TrackedArray } from "@ember-compat/tracked-built-ins"; -import { ajax } from "discourse/lib/ajax"; -import { bind } from "discourse/lib/decorators"; -import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; import { withPluginApi } from "discourse/lib/plugin-api"; -import { i18n } from "discourse-i18n"; -import AiBotSidebarEmptyState from "../discourse/components/ai-bot-sidebar-empty-state"; import AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation"; import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; @@ -28,8 +20,7 @@ export default { const aiConversationsSidebarManager = api.container.lookup( "service:ai-conversations-sidebar-manager" ); - const appEvents = api.container.lookup("service:app-events"); - const messageBus = api.container.lookup("service:message-bus"); + aiConversationsSidebarManager.api = api; api.addSidebarPanel( (BaseCustomSidebarPanel) => @@ -45,293 +36,6 @@ export default { "before-sidebar-sections", AiBotSidebarNewConversation ); - api.addSidebarSection( - (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { - const AiConversationLink = class extends BaseCustomSidebarSectionLink { - route = "topic.fromParamsNear"; - - constructor(topic) { - super(...arguments); - this.topic = topic; - } - - get key() { - return this.topic.id; - } - - get name() { - return this.topic.title; - } - - get models() { - return [ - this.topic.slug, - this.topic.id, - this.topic.last_read_post_number || 0, - ]; - } - - get title() { - return this.topic.title; - } - - get text() { - return this.topic.title; - } - - get classNames() { - return `ai-conversation-${this.topic.id}`; - } - }; - - return class extends BaseCustomSidebarSection { - @tracked links = new TrackedArray(); - @tracked topics = []; - @tracked hasMore = []; - @tracked loadedTodayLabel = false; - @tracked loadedSevenDayLabel = false; - @tracked loadedThirtyDayLabel = false; - @tracked loadedMonthLabels = new Set(); - @tracked isLoading = true; - isFetching = false; - page = 0; - totalTopicsCount = 0; - - constructor() { - super(...arguments); - this.fetchMessages(); - - appEvents.on( - "discourse-ai:bot-pm-created", - this, - "addNewPMToSidebar" - ); - } - - @bind - willDestroy() { - this.removeScrollListener(); - appEvents.off( - "discourse-ai:bot-pm-created", - this, - "addNewPMToSidebar" - ); - } - - get name() { - return "ai-conversations-history"; - } - - get emptyStateComponent() { - if (!this.isLoading) { - return AiBotSidebarEmptyState; - } - } - - get text() { - return i18n( - "discourse_ai.ai_bot.conversations.messages_sidebar_title" - ); - } - - get sidebarElement() { - return document.querySelector( - ".sidebar-wrapper .sidebar-sections" - ); - } - - addNewPMToSidebar(topic) { - // Reset category labels since we're adding a new topic - this.loadedTodayLabel = false; - this.loadedSevenDayLabel = false; - this.loadedThirtyDayLabel = false; - this.loadedMonthLabels.clear(); - - this.topics = [topic, ...this.topics]; - this.buildSidebarLinks(); - - this.watchForTitleUpdate(topic); - } - - @bind - removeScrollListener() { - const sidebar = this.sidebarElement; - if (sidebar) { - sidebar.removeEventListener("scroll", this.scrollHandler); - } - } - - @bind - attachScrollListener() { - const sidebar = this.sidebarElement; - if (sidebar) { - sidebar.addEventListener("scroll", this.scrollHandler); - } - } - - @bind - scrollHandler() { - const sidebarElement = this.sidebarElement; - if (!sidebarElement) { - return; - } - - const scrollPosition = sidebarElement.scrollTop; - const scrollHeight = sidebarElement.scrollHeight; - const clientHeight = sidebarElement.clientHeight; - - // When user has scrolled to bottom with a small threshold - if (scrollHeight - scrollPosition - clientHeight < 100) { - if (this.hasMore && !this.isFetching) { - this.loadMore(); - } - } - } - - async fetchMessages(isLoadingMore = false) { - if (this.isFetching) { - return; - } - - try { - this.isFetching = true; - const data = await ajax( - "/discourse-ai/ai-bot/conversations.json", - { - data: { page: this.page, per_page: 40 }, - } - ); - - if (isLoadingMore) { - this.topics = [...this.topics, ...data.conversations]; - } else { - this.topics = data.conversations; - } - - this.totalTopicsCount = data.meta.total; - this.hasMore = data.meta.has_more; - this.isFetching = false; - this.removeScrollListener(); - this.buildSidebarLinks(); - this.attachScrollListener(); - } catch { - this.isFetching = false; - } finally { - this.isLoading = false; - } - } - - loadMore() { - if (this.isFetching || !this.hasMore) { - return; - } - - this.page = this.page + 1; - this.fetchMessages(true); - } - - groupByDate(topic) { - const now = new Date(); - const lastPostedAt = new Date(topic.last_posted_at); - const daysDiff = Math.round( - (now - lastPostedAt) / (1000 * 60 * 60 * 24) - ); - - if (daysDiff <= 1 || !topic.last_posted_at) { - if (!this.loadedTodayLabel) { - this.loadedTodayLabel = true; - return { - text: i18n("discourse_ai.ai_bot.conversations.today"), - classNames: "date-heading", - name: "date-heading-today", - }; - } - } - // Last 7 days group - else if (daysDiff <= 7) { - if (!this.loadedSevenDayLabel) { - this.loadedSevenDayLabel = true; - return { - text: i18n("discourse_ai.ai_bot.conversations.last_7_days"), - classNames: "date-heading", - name: "date-heading-last-7-days", - }; - } - } - // Last 30 days group - else if (daysDiff <= 30) { - if (!this.loadedThirtyDayLabel) { - this.loadedThirtyDayLabel = true; - return { - text: i18n( - "discourse_ai.ai_bot.conversations.last_30_days" - ), - classNames: "date-heading", - name: "date-heading-last-30-days", - }; - } - } - // Group by month for older conversations - else { - const month = lastPostedAt.getMonth(); - const year = lastPostedAt.getFullYear(); - const monthKey = `${year}-${month}`; - - if (!this.loadedMonthLabels.has(monthKey)) { - this.loadedMonthLabels.add(monthKey); - - const formattedDate = autoUpdatingRelativeAge( - new Date(topic.last_posted_at) - ); - - return { - text: htmlSafe(formattedDate), - classNames: "date-heading", - name: `date-heading-${monthKey}`, - }; - } - } - } - - buildSidebarLinks() { - // Reset date header tracking - this.loadedTodayLabel = false; - this.loadedSevenDayLabel = false; - this.loadedThirtyDayLabel = false; - this.loadedMonthLabels.clear(); - - this.links = [...this.topics].flatMap((topic) => { - const dateLabel = this.groupByDate(topic); - return dateLabel - ? [dateLabel, new AiConversationLink(topic)] - : [new AiConversationLink(topic)]; - }); - } - - watchForTitleUpdate(topic) { - const channel = `/discourse-ai/ai-bot/topic/${topic.id}`; - const callback = this.updateTopicTitle.bind(this); - messageBus.subscribe(channel, ({ title }) => { - callback(topic, title); - messageBus.unsubscribe(channel); - }); - } - - updateTopicTitle(topic, title) { - // update the data - topic.title = title; - - // force Glimmer to re-render that one link - this.links = this.links.map((link) => - link?.topic?.id === topic.id - ? new AiConversationLink(topic) - : link - ); - } - }; - }, - AI_CONVERSATIONS_PANEL - ); const setSidebarPanel = (transition) => { if (transition?.to?.name === "discourse-ai-bot-conversations") { @@ -349,15 +53,6 @@ export default { return aiConversationsSidebarManager.forceCustomSidebar(); } - // newTopicForceSidebar is set to true when a new topic is created. We have - // this because the condition `postStream.posts` above will not be true as the bot response - // is not in the postStream yet when this initializer is ran. So we need to force - // the sidebar to open when creating a new topic. After that, we set it to false again. - if (aiConversationsSidebarManager.newTopicForceSidebar) { - aiConversationsSidebarManager.newTopicForceSidebar = false; - return aiConversationsSidebarManager.forceCustomSidebar(); - } - aiConversationsSidebarManager.stopForcingCustomSidebar(); }; diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss index c6f0a02d..8b3b01e2 100644 --- a/assets/stylesheets/modules/ai-bot-conversations/common.scss +++ b/assets/stylesheets/modules/ai-bot-conversations/common.scss @@ -27,48 +27,6 @@ body.has-ai-conversations-sidebar { display: none; } - .sidebar-wrapper, - .hamburger-dropdown-wrapper { - // ai related sidebar content - [data-section-name="ai-conversations-history"] { - .sidebar-section-header-wrapper { - display: none; - } - - .sidebar-section-link-wrapper { - .sidebar-section-link.date-heading { - pointer-events: none; - cursor: default; - color: var(--primary-medium); - opacity: 0.8; - font-weight: 700; - margin-top: 1em; - font-size: var(--font-down-2); - } - - .sidebar-section-link { - height: unset; - padding-block: 0.65em; - font-size: var(--font-down-1); - letter-spacing: 0.35px; - border-radius: 0 var(--border-radius) var(--border-radius) 0; - - .sidebar-section-link-content-text { - white-space: normal; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - } - } - } - - .sidebar-section-link-prefix { - align-self: start; - } - } - } - // topic elements #topic-footer-button-share-and-invite, body:not(.staff) #topic-footer-button-archive, diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb index 3510c32f..f2b0bab1 100644 --- a/spec/system/ai_bot/homepage_spec.rb +++ b/spec/system/ai_bot/homepage_spec.rb @@ -222,8 +222,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("ai-conversations-history") - expect(sidebar).to have_section_link("Today") + expect(sidebar).to have_section("Today") expect(sidebar).to have_section_link(pm.title) end @@ -233,7 +232,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Last 7 days") + expect(sidebar).to have_section("Last 7 days") end it "displays last_30_days label in the sidebar" do @@ -242,7 +241,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Last 30 days") + expect(sidebar).to have_section("Last 30 days") end it "displays month and year label in the sidebar for older conversations" do @@ -251,7 +250,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Apr 2024") + expect(sidebar).to have_section("2024-3") end it "navigates to the bot conversation when clicked" do @@ -328,12 +327,6 @@ RSpec.describe "AI Bot - Homepage", type: :system do expect(sidebar).to have_no_section_link(pm.title) end - it "renders empty state in sidebar with no bot PM history" do - sign_in(user_2) - ai_pm_homepage.visit - expect(ai_pm_homepage).to have_empty_state - end - it "Allows choosing persona and LLM" do ai_pm_homepage.visit diff --git a/spec/system/page_objects/components/ai_pm_homepage.rb b/spec/system/page_objects/components/ai_pm_homepage.rb index 69b93af3..06773b05 100644 --- a/spec/system/page_objects/components/ai_pm_homepage.rb +++ b/spec/system/page_objects/components/ai_pm_homepage.rb @@ -52,13 +52,9 @@ module PageObjects page.find(".ai-new-question-button").click end - def has_empty_state? - page.has_css?(".ai-bot-sidebar-empty-state") - end - def click_fist_sidebar_conversation page.find( - ".sidebar-section[data-section-name='ai-conversations-history'] a.sidebar-section-link:not(.date-heading)", + ".sidebar-section-content a.sidebar-section-link", ).click end