REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389)

This commit is contained in:
Kris 2025-06-03 10:40:52 -04:00 committed by GitHub
parent 306fec2b24
commit fa51e9d948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 295 additions and 384 deletions

View File

@ -1,48 +1,317 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { scheduleOnce } from "@ember/runloop";
import Service, { service } from "@ember/service"; 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 { 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"; export const AI_CONVERSATIONS_PANEL = "ai-conversations";
const SCROLL_BUFFER = 100;
const DEBOUNCE = 100;
export default class AiConversationsSidebarManager extends Service { export default class AiConversationsSidebarManager extends Service {
@service appEvents; @service appEvents;
@service sidebarState; @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() { forceCustomSidebar() {
// Return early if we already have the correct panel, so we don't document.body.classList.add("has-ai-conversations-sidebar");
// re-render it. 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.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(); this.sidebarState.setSeparatedMode();
// Hide panel switching buttons to keep UI clean
this.sidebarState.hideSwitchPanelButtons(); this.sidebarState.hideSwitchPanelButtons();
this.sidebarState.isForcingSidebar = true; // don't render sidebar multiple times
document.body.classList.add("has-ai-conversations-sidebar"); if (this._didInit) {
this.appEvents.trigger("discourse-ai:force-conversations-sidebar"); return true;
}
this._didInit = true;
this.fetchMessages().then(() => {
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
});
return true; 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() { 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"); document.body.classList.remove("has-ai-conversations-sidebar");
const isAdminSidebarActive =
this.sidebarState.currentPanel?.key === ADMIN_PANEL; const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL;
// only restore main panel if we previously forced our sidebar if (this.sidebarState.isForcingSidebar && !isAdmin) {
// and not if we are in admin sidebar this.sidebarState.setPanel(MAIN_PANEL);
if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) {
this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel
this.sidebarState.isForcingSidebar = false; this.sidebarState.isForcingSidebar = false;
this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar"); 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);
}
} }
} }

View File

@ -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 { 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 AiBotSidebarNewConversation from "../discourse/components/ai-bot-sidebar-new-conversation";
import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager"; import { AI_CONVERSATIONS_PANEL } from "../discourse/services/ai-conversations-sidebar-manager";
@ -28,8 +20,7 @@ export default {
const aiConversationsSidebarManager = api.container.lookup( const aiConversationsSidebarManager = api.container.lookup(
"service:ai-conversations-sidebar-manager" "service:ai-conversations-sidebar-manager"
); );
const appEvents = api.container.lookup("service:app-events"); aiConversationsSidebarManager.api = api;
const messageBus = api.container.lookup("service:message-bus");
api.addSidebarPanel( api.addSidebarPanel(
(BaseCustomSidebarPanel) => (BaseCustomSidebarPanel) =>
@ -45,293 +36,6 @@ export default {
"before-sidebar-sections", "before-sidebar-sections",
AiBotSidebarNewConversation 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) => { const setSidebarPanel = (transition) => {
if (transition?.to?.name === "discourse-ai-bot-conversations") { if (transition?.to?.name === "discourse-ai-bot-conversations") {
@ -349,15 +53,6 @@ export default {
return aiConversationsSidebarManager.forceCustomSidebar(); 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(); aiConversationsSidebarManager.stopForcingCustomSidebar();
}; };

View File

@ -27,48 +27,6 @@ body.has-ai-conversations-sidebar {
display: none; 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 elements
#topic-footer-button-share-and-invite, #topic-footer-button-share-and-invite,
body:not(.staff) #topic-footer-button-archive, body:not(.staff) #topic-footer-button-archive,

View File

@ -222,8 +222,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do
header.click_bot_button header.click_bot_button
expect(ai_pm_homepage).to have_homepage expect(ai_pm_homepage).to have_homepage
expect(sidebar).to have_section("ai-conversations-history") expect(sidebar).to have_section("Today")
expect(sidebar).to have_section_link("Today")
expect(sidebar).to have_section_link(pm.title) expect(sidebar).to have_section_link(pm.title)
end end
@ -233,7 +232,7 @@ RSpec.describe "AI Bot - Homepage", type: :system do
header.click_bot_button header.click_bot_button
expect(ai_pm_homepage).to have_homepage 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 end
it "displays last_30_days label in the sidebar" do 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 header.click_bot_button
expect(ai_pm_homepage).to have_homepage 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 end
it "displays month and year label in the sidebar for older conversations" do 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 header.click_bot_button
expect(ai_pm_homepage).to have_homepage expect(ai_pm_homepage).to have_homepage
expect(sidebar).to have_section_link("Apr 2024") expect(sidebar).to have_section("2024-3")
end end
it "navigates to the bot conversation when clicked" do 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) expect(sidebar).to have_no_section_link(pm.title)
end 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 it "Allows choosing persona and LLM" do
ai_pm_homepage.visit ai_pm_homepage.visit

View File

@ -52,13 +52,9 @@ module PageObjects
page.find(".ai-new-question-button").click page.find(".ai-new-question-button").click
end end
def has_empty_state?
page.has_css?(".ai-bot-sidebar-empty-state")
end
def click_fist_sidebar_conversation def click_fist_sidebar_conversation
page.find( 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 ).click
end end