From 4eb05e6a85e26a9c2fa627a67c45e03de35e158b Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Thu, 29 May 2025 17:38:39 -0400 Subject: [PATCH 1/5] WIP: use separate sidebar sections for AI conversation history --- .../ai-conversations-sidebar-manager.js | 217 ++++++++++++- .../initializers/ai-conversations-sidebar.js | 303 +----------------- 2 files changed, 219 insertions(+), 301 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index 9e7c45b9f..56aa37f42 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -1,6 +1,13 @@ import { tracked } from "@glimmer/tracking"; +import { schedule } 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 { 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"; @@ -9,14 +16,31 @@ export default class AiConversationsSidebarManager extends Service { @service sidebarState; @tracked newTopicForceSidebar = false; + @tracked sections = new TrackedArray(); + @tracked isLoading = true; - forceCustomSidebar() { + isFetching = false; + page = 0; + hasMore = true; + + loadedTodayLabel = false; + loadedSevenDayLabel = false; + loadedThirtyDayLabel = false; + loadedMonthLabels = new Set(); + + forceCustomSidebar(api) { // 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; } + schedule("afterRender", async () => { + await this.fetchMessages(api); + this.sidebarState.setPanel("ai-conversations"); + }); + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); // Use separated mode to ensure independence from hamburger menu @@ -45,4 +69,195 @@ export default class AiConversationsSidebarManager extends Service { this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar"); } } + + async fetchMessages(api) { + if (this.isFetching) { + return; + } + this.isFetching = true; + + try { + let { conversations, meta } = await ajax( + "/discourse-ai/ai-bot/conversations.json", + { data: { page: this.page, per_page: 40 } } + ); + + this.page += 1; + this.hasMore = meta.has_more; + + // Append new topics and rebuild groups + this._topics = [...(this._topics || []), ...conversations]; + } catch { + this.isFetching = false; + this.isLoading = false; + } finally { + this.isFetching = false; + this.isLoading = false; + this.buildSections(api); + } + } + + buildSections(api) { + // reset grouping flags + this.loadedTodayLabel = false; + this.loadedSevenDayLabel = false; + this.loadedThirtyDayLabel = false; + this.loadedMonthLabels.clear(); + + const now = new Date(); + const sections = []; + let currentSection = null; + + (this._topics || []).forEach((topic) => { + const heading = this.groupByDate(topic, now); + + // new section for new heading + if (heading) { + currentSection = { + title: heading.text, + name: heading.name, + classNames: heading.classNames, + links: new TrackedArray(), + }; + sections.push(currentSection); + } + + // always add topic link under the latest section + if (currentSection) { + currentSection.links.push({ + route: "topic.fromParamsNear", + models: [topic.slug, topic.id, topic.last_read_post_number || 0], + title: topic.title, + text: topic.title, + key: topic.id, + classNames: `ai-conversation-${topic.id}`, + }); + } + }); + + this.sections = sections; + + this.mountSections(api); + } + + mountSections(api) { + this.sections.forEach((section) => { + api.addSidebarSection( + (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { + return class extends BaseCustomSidebarSection { + get name() { + return section.name; + } + + get title() { + return section.title; + } + + get text() { + return section.title; + } + + get links() { + return section.links.map( + (link) => + new (class extends BaseCustomSidebarSectionLink { + get name() { + return `conv-${link.key}`; + } + + get route() { + return link.route; + } + + get models() { + return link.models; + } + + get title() { + return link.title; + } + + get text() { + return link.text; + } + })() + ); + } + + get emptyStateComponent() { + if (!this.isLoading && section.links.length === 0) { + return AiBotSidebarEmptyState; + } + } + + get sidebarElement() { + return document.querySelector( + ".sidebar-wrapper .sidebar-sections" + ); + } + }; + }, + AI_CONVERSATIONS_PANEL + ); + }); + + this.appEvents.trigger("discourse-ai:conversations-sidebar-updated"); + } + + groupByDate(topic, now = new Date()) { + const lastPostedAt = new Date(topic.last_posted_at); + const daysDiff = Math.round((now - lastPostedAt) / (1000 * 60 * 60 * 24)); + + // Today + 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 + 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 + 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", + }; + } + } + // Older: group by month + 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(lastPostedAt); + return { + text: htmlSafe(formattedDate), + classNames: "date-heading", + name: `date-heading-${monthKey}`, + }; + } + } + + return null; + } } diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index e288f203c..3bd7ed615 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,6 @@ 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"); api.addSidebarPanel( (BaseCustomSidebarPanel) => @@ -45,297 +35,10 @@ 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") { - return aiConversationsSidebarManager.forceCustomSidebar(); + return aiConversationsSidebarManager.forceCustomSidebar(api); } const topic = api.container.lookup("controller:topic").model; @@ -346,7 +49,7 @@ export default { topic.user_id === currentUser.id && topic.is_bot_pm ) { - return aiConversationsSidebarManager.forceCustomSidebar(); + return aiConversationsSidebarManager.forceCustomSidebar(api); } // newTopicForceSidebar is set to true when a new topic is created. We have @@ -355,7 +58,7 @@ export default { // 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(); + return aiConversationsSidebarManager.forceCustomSidebar(api); } aiConversationsSidebarManager.stopForcingCustomSidebar(); From 9250359768f8b41c798de6052b979891a7bf3d07 Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Fri, 30 May 2025 15:48:20 -0400 Subject: [PATCH 2/5] get everything working and cleaned up --- .../ai-conversations-sidebar-manager.js | 426 ++++++++++-------- .../initializers/ai-conversations-sidebar.js | 14 +- 2 files changed, 243 insertions(+), 197 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index 56aa37f42..dfa691888 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -1,79 +1,160 @@ import { tracked } from "@glimmer/tracking"; -import { schedule } from "@ember/runloop"; +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 + ); + } - loadedTodayLabel = false; - loadedSevenDayLabel = false; - loadedThirtyDayLabel = false; - loadedMonthLabels = new Set(); + 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(api) { - // Return early if we already have the correct panel, so we don't - // re-render it. + forceCustomSidebar() { + document.body.classList.add("has-ai-conversations-sidebar"); + this.sidebarState.isForcingSidebar = true; - if (this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL) { - return; + // calling this before fetching data + // helps avoid flash of main sidebar mode + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + + this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); + this.sidebarState.setSeparatedMode(); + this.sidebarState.hideSwitchPanelButtons(); + + // don't render sidebar multiple times + if (this._didInit) { + return true; } - schedule("afterRender", async () => { - await this.fetchMessages(api); - this.sidebarState.setPanel("ai-conversations"); + this._didInit = true; + + this.fetchMessages().then(() => { + this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); }); - this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL); + return true; + } - // Use separated mode to ensure independence from hamburger menu - this.sidebarState.setSeparatedMode(); + _attachScrollListener() { + const sections = document.querySelector( + ".sidebar-sections.ai-conversations-panel" + ); + this._scrollElement = sections; - // Hide panel switching buttons to keep UI clean - this.sidebarState.hideSwitchPanelButtons(); + if (this._hasScrollListener || !this._scrollElement) { + return; + } - this.sidebarState.isForcingSidebar = true; - document.body.classList.add("has-ai-conversations-sidebar"); - this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); - return true; + 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(api) { - if (this.isFetching) { + async fetchMessages() { + if (this.isFetching || !this.hasMore) { return; } + + const isFirstPage = this.page === 0; this.isFetching = true; try { @@ -82,182 +163,155 @@ export default class AiConversationsSidebarManager extends Service { { 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; - // Append new topics and rebuild groups - this._topics = [...(this._topics || []), ...conversations]; - } catch { - this.isFetching = false; - this.isLoading = false; + this._rebuildSections(); } finally { this.isFetching = false; this.isLoading = false; - this.buildSections(api); } } - buildSections(api) { - // reset grouping flags - this.loadedTodayLabel = false; - this.loadedSevenDayLabel = false; - this.loadedThirtyDayLabel = false; - this.loadedMonthLabels.clear(); - - const now = new Date(); - const sections = []; - let currentSection = null; - - (this._topics || []).forEach((topic) => { - const heading = this.groupByDate(topic, now); - - // new section for new heading - if (heading) { - currentSection = { - title: heading.text, - name: heading.name, - classNames: heading.classNames, - links: new TrackedArray(), - }; - sections.push(currentSection); - } + _handleNewBotPM(topic) { + this.topics = [topic, ...this.topics]; + this._rebuildSections(); + this._watchForTitleUpdate(topic.id); + } - // always add topic link under the latest section - if (currentSection) { - currentSection.links.push({ - route: "topic.fromParamsNear", - models: [topic.slug, topic.id, topic.last_read_post_number || 0], - title: topic.title, - text: topic.title, - key: topic.id, - classNames: `ai-conversation-${topic.id}`, - }); - } - }); + _watchForTitleUpdate(topicId) { + if (this._subscribedTopicIds?.has(topicId)) { + return; + } - this.sections = sections; + this._subscribedTopicIds = this._subscribedTopicIds || new Set(); + this._subscribedTopicIds.add(topicId); - this.mountSections(api); - } + const channel = `/discourse-ai/ai-bot/topic/${topicId}`; - mountSections(api) { - this.sections.forEach((section) => { - api.addSidebarSection( - (BaseCustomSidebarSection, BaseCustomSidebarSectionLink) => { - return class extends BaseCustomSidebarSection { - get name() { - return section.name; - } + this.messageBus.subscribe(channel, (payload) => { + this._applyTitleUpdate(topicId, payload.title); + this.messageBus.unsubscribe(channel); + }); + } - get title() { - return section.title; - } + _applyTitleUpdate(topicId, newTitle) { + this.topics = this.topics.map((t) => + t.id === topicId ? { ...t, title: newTitle } : t + ); - get text() { - return section.title; - } + this._rebuildSections(); + } - get links() { - return section.links.map( - (link) => - new (class extends BaseCustomSidebarSectionLink { - get name() { - return `conv-${link.key}`; - } - - get route() { - return link.route; - } - - get models() { - return link.models; - } - - get title() { - return link.title; - } - - get text() { - return link.text; - } - })() - ); - } + // 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; + } - get emptyStateComponent() { - if (!this.isLoading && section.links.length === 0) { - return AiBotSidebarEmptyState; - } - } + 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); + } - get sidebarElement() { - return document.querySelector( - ".sidebar-wrapper .sidebar-sections" - ); - } - }; - }, - AI_CONVERSATIONS_PANEL - ); + 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.appEvents.trigger("discourse-ai:conversations-sidebar-updated"); - } + this.sections = new TrackedArray(fresh); - groupByDate(topic, now = new Date()) { - const lastPostedAt = new Date(topic.last_posted_at); - const daysDiff = Math.round((now - lastPostedAt) / (1000 * 60 * 60 * 24)); - - // Today - 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 - 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 - 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", - }; + // register each new section once + for (let sec of fresh) { + if (this._registered.has(sec.name)) { + continue; } - } - // Older: group by month - 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(lastPostedAt); - return { - text: htmlSafe(formattedDate), - classNames: "date-heading", - name: `date-heading-${monthKey}`, + 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); } - - return null; } } diff --git a/assets/javascripts/initializers/ai-conversations-sidebar.js b/assets/javascripts/initializers/ai-conversations-sidebar.js index 3bd7ed615..de5e73053 100644 --- a/assets/javascripts/initializers/ai-conversations-sidebar.js +++ b/assets/javascripts/initializers/ai-conversations-sidebar.js @@ -20,6 +20,7 @@ export default { const aiConversationsSidebarManager = api.container.lookup( "service:ai-conversations-sidebar-manager" ); + aiConversationsSidebarManager.api = api; api.addSidebarPanel( (BaseCustomSidebarPanel) => @@ -38,7 +39,7 @@ export default { const setSidebarPanel = (transition) => { if (transition?.to?.name === "discourse-ai-bot-conversations") { - return aiConversationsSidebarManager.forceCustomSidebar(api); + return aiConversationsSidebarManager.forceCustomSidebar(); } const topic = api.container.lookup("controller:topic").model; @@ -49,16 +50,7 @@ export default { topic.user_id === currentUser.id && topic.is_bot_pm ) { - return aiConversationsSidebarManager.forceCustomSidebar(api); - } - - // 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(api); + return aiConversationsSidebarManager.forceCustomSidebar(); } aiConversationsSidebarManager.stopForcingCustomSidebar(); From 83f447bd509ee48a25f5d246fc8d5d877ea5a61e Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Fri, 30 May 2025 15:56:12 -0400 Subject: [PATCH 3/5] remove custom styling --- .../modules/ai-bot-conversations/common.scss | 42 ------------------- 1 file changed, 42 deletions(-) diff --git a/assets/stylesheets/modules/ai-bot-conversations/common.scss b/assets/stylesheets/modules/ai-bot-conversations/common.scss index c6f0a02d4..8b3b01e26 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, From 508a61a956d72699f6fb685aade0db4c9753207c Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Fri, 30 May 2025 16:29:11 -0400 Subject: [PATCH 4/5] update tests --- spec/system/ai_bot/homepage_spec.rb | 15 ++++----------- .../page_objects/components/ai_pm_homepage.rb | 6 +----- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb index 3510c32fd..059fda9a1 100644 --- a/spec/system/ai_bot/homepage_spec.rb +++ b/spec/system/ai_bot/homepage_spec.rb @@ -222,8 +222,7 @@ 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 @@ 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 @@ 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 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section_link("Apr 2024") + expect(sidebar).to have_section("Apr 2024") end it "navigates to the bot conversation when clicked" do @@ -328,12 +327,6 @@ 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 69b93af37..06773b05b 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 @@ def click_new_question_button 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 From 69290e0ac16dfe45a890918b579c3c915c9230ee Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Fri, 30 May 2025 16:47:43 -0400 Subject: [PATCH 5/5] update tests --- .../services/ai-conversations-sidebar-manager.js | 8 ++++---- spec/system/ai_bot/homepage_spec.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js index dfa691888..64c4f3762 100644 --- a/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js +++ b/assets/javascripts/discourse/services/ai-conversations-sidebar-manager.js @@ -224,9 +224,9 @@ export default class AiConversationsSidebarManager extends Service { if (diffDays <= 1) { dateGroup = "today"; } else if (diffDays <= 7) { - dateGroup = "last_7_days"; + dateGroup = "last-7-days"; } else if (diffDays <= 30) { - dateGroup = "last_30_days"; + dateGroup = "last-30-days"; } else { const d = new Date(postedAtMs); const key = `${d.getFullYear()}-${d.getMonth()}`; @@ -240,10 +240,10 @@ export default class AiConversationsSidebarManager extends Service { case "today": title = i18n("discourse_ai.ai_bot.conversations.today"); break; - case "last_7_days": + case "last-7-days": title = i18n("discourse_ai.ai_bot.conversations.last_7_days"); break; - case "last_30_days": + case "last-30-days": title = i18n("discourse_ai.ai_bot.conversations.last_30_days"); break; default: diff --git a/spec/system/ai_bot/homepage_spec.rb b/spec/system/ai_bot/homepage_spec.rb index 059fda9a1..f2b0bab17 100644 --- a/spec/system/ai_bot/homepage_spec.rb +++ b/spec/system/ai_bot/homepage_spec.rb @@ -250,7 +250,7 @@ header.click_bot_button expect(ai_pm_homepage).to have_homepage - expect(sidebar).to have_section("Apr 2024") + expect(sidebar).to have_section("2024-3") end it "navigates to the bot conversation when clicked" do