Files
discourse-ai/assets/javascripts/initializers/ai-conversations-sidebar.js
T
Mark VanLandingham 1e32416eaa UX: Empty state for AI conversations sidebar & btn changes (#1297)
This commit adds an empty state when the user doesn't have any PM history. It ALSO retains the new conversation button in the sidebar so it no longer jumps. The button is disabled, icon, and text are all updated.
2025-04-30 11:33:41 -05:00

366 lines
12 KiB
JavaScript

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";
export default {
name: "ai-conversations-sidebar",
initialize() {
withPluginApi((api) => {
const siteSettings = api.container.lookup("service:site-settings");
if (!siteSettings.ai_enable_experimental_bot_ux) {
return;
}
const currentUser = api.container.lookup("service:current-user");
if (!currentUser) {
return;
}
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");
const navigationMenu = api.container.lookup("service:navigationMenu");
api.addSidebarPanel(
(BaseCustomSidebarPanel) =>
class AiConversationsSidebarPanel extends BaseCustomSidebarPanel {
key = AI_CONVERSATIONS_PANEL;
hidden = true;
displayHeader = !navigationMenu.isHeaderDropdownMode;
expandActiveSection = true;
}
);
api.renderInOutlet(
"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();
page = 0;
isFetching = false;
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() {
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;
}
}
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();
}
const topic = api.container.lookup("controller:topic").model;
// if the topic is not a private message, not created by the current user,
// or doesn't have a bot response, we don't need to override sidebar
if (
topic?.archetype === "private_message" &&
topic.user_id === currentUser.id &&
topic.is_bot_pm
) {
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();
};
api.container
.lookup("service:router")
.on("routeDidChange", setSidebarPanel);
});
},
};