Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading