Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit fa51e9d

Browse files
authored
REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389)
1 parent 306fec2 commit fa51e9d

File tree

5 files changed

+295
-384
lines changed

5 files changed

+295
-384
lines changed
Lines changed: 289 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,317 @@
11
import { tracked } from "@glimmer/tracking";
2+
import { scheduleOnce } from "@ember/runloop";
23
import Service, { service } from "@ember/service";
4+
import { htmlSafe } from "@ember/template";
5+
import { TrackedArray } from "@ember-compat/tracked-built-ins";
6+
import { ajax } from "discourse/lib/ajax";
7+
import discourseDebounce from "discourse/lib/debounce";
8+
import { autoUpdatingRelativeAge } from "discourse/lib/formatter";
39
import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels";
10+
import { i18n } from "discourse-i18n";
11+
import AiBotSidebarEmptyState from "../../discourse/components/ai-bot-sidebar-empty-state";
412

513
export const AI_CONVERSATIONS_PANEL = "ai-conversations";
14+
const SCROLL_BUFFER = 100;
15+
const DEBOUNCE = 100;
616

717
export default class AiConversationsSidebarManager extends Service {
818
@service appEvents;
919
@service sidebarState;
20+
@service messageBus;
1021

11-
@tracked newTopicForceSidebar = false;
22+
@tracked topics = [];
23+
@tracked sections = new TrackedArray();
24+
@tracked isLoading = true;
25+
26+
api = null;
27+
isFetching = false;
28+
page = 0;
29+
hasMore = true;
30+
_registered = new Set();
31+
_hasScrollListener = false;
32+
_scrollElement = null;
33+
_didInit = false;
34+
35+
_debouncedScrollHandler = () => {
36+
discourseDebounce(
37+
this,
38+
() => {
39+
const element = this._scrollElement;
40+
if (!element) {
41+
return;
42+
}
43+
44+
const { scrollTop, scrollHeight, clientHeight } = element;
45+
if (
46+
scrollHeight - scrollTop - clientHeight - SCROLL_BUFFER < 100 &&
47+
!this.isFetching &&
48+
this.hasMore
49+
) {
50+
this.fetchMessages();
51+
}
52+
},
53+
DEBOUNCE
54+
);
55+
};
56+
57+
constructor() {
58+
super(...arguments);
59+
60+
this.appEvents.on(
61+
"discourse-ai:bot-pm-created",
62+
this,
63+
this._handleNewBotPM
64+
);
65+
66+
this.appEvents.on(
67+
"discourse-ai:conversations-sidebar-updated",
68+
this,
69+
this._attachScrollListener
70+
);
71+
}
72+
73+
willDestroy() {
74+
super.willDestroy(...arguments);
75+
this.appEvents.off(
76+
"discourse-ai:bot-pm-created",
77+
this,
78+
this._handleNewBotPM
79+
);
80+
this.appEvents.off(
81+
"discourse-ai:conversations-sidebar-updated",
82+
this,
83+
this._attachScrollListener
84+
);
85+
}
1286

1387
forceCustomSidebar() {
14-
// Return early if we already have the correct panel, so we don't
15-
// re-render it.
16-
if (this.sidebarState.currentPanel?.key === AI_CONVERSATIONS_PANEL) {
17-
return;
18-
}
88+
document.body.classList.add("has-ai-conversations-sidebar");
89+
this.sidebarState.isForcingSidebar = true;
1990

91+
// calling this before fetching data
92+
// helps avoid flash of main sidebar mode
2093
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
2194

22-
// Use separated mode to ensure independence from hamburger menu
95+
this.appEvents.trigger("discourse-ai:force-conversations-sidebar");
2396
this.sidebarState.setSeparatedMode();
24-
25-
// Hide panel switching buttons to keep UI clean
2697
this.sidebarState.hideSwitchPanelButtons();
2798

28-
this.sidebarState.isForcingSidebar = true;
29-
document.body.classList.add("has-ai-conversations-sidebar");
30-
this.appEvents.trigger("discourse-ai:force-conversations-sidebar");
99+
// don't render sidebar multiple times
100+
if (this._didInit) {
101+
return true;
102+
}
103+
104+
this._didInit = true;
105+
106+
this.fetchMessages().then(() => {
107+
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
108+
});
109+
31110
return true;
32111
}
33112

113+
_attachScrollListener() {
114+
const sections = document.querySelector(
115+
".sidebar-sections.ai-conversations-panel"
116+
);
117+
this._scrollElement = sections;
118+
119+
if (this._hasScrollListener || !this._scrollElement) {
120+
return;
121+
}
122+
123+
sections.addEventListener("scroll", this._debouncedScrollHandler);
124+
125+
this._hasScrollListener = true;
126+
}
127+
128+
_removeScrollListener() {
129+
if (this._hasScrollListener) {
130+
this._scrollElement.removeEventListener(
131+
"scroll",
132+
this._debouncedScrollHandler
133+
);
134+
this._hasScrollListener = false;
135+
this._scrollElement = null;
136+
}
137+
}
138+
34139
stopForcingCustomSidebar() {
35-
// This method is called when leaving your route
36-
// Only restore main panel if we previously forced ours
37140
document.body.classList.remove("has-ai-conversations-sidebar");
38-
const isAdminSidebarActive =
39-
this.sidebarState.currentPanel?.key === ADMIN_PANEL;
40-
// only restore main panel if we previously forced our sidebar
41-
// and not if we are in admin sidebar
42-
if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) {
43-
this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel
141+
142+
const isAdmin = this.sidebarState.currentPanel?.key === ADMIN_PANEL;
143+
if (this.sidebarState.isForcingSidebar && !isAdmin) {
144+
this.sidebarState.setPanel(MAIN_PANEL);
44145
this.sidebarState.isForcingSidebar = false;
45146
this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar");
46147
}
148+
149+
this._removeScrollListener();
150+
}
151+
152+
async fetchMessages() {
153+
if (this.isFetching || !this.hasMore) {
154+
return;
155+
}
156+
157+
const isFirstPage = this.page === 0;
158+
this.isFetching = true;
159+
160+
try {
161+
let { conversations, meta } = await ajax(
162+
"/discourse-ai/ai-bot/conversations.json",
163+
{ data: { page: this.page, per_page: 40 } }
164+
);
165+
166+
if (isFirstPage) {
167+
this.topics = conversations;
168+
} else {
169+
this.topics = [...this.topics, ...conversations];
170+
// force rerender when fetching more messages
171+
this.sidebarState.setPanel(AI_CONVERSATIONS_PANEL);
172+
}
173+
174+
this.page += 1;
175+
this.hasMore = meta.has_more;
176+
177+
this._rebuildSections();
178+
} finally {
179+
this.isFetching = false;
180+
this.isLoading = false;
181+
}
182+
}
183+
184+
_handleNewBotPM(topic) {
185+
this.topics = [topic, ...this.topics];
186+
this._rebuildSections();
187+
this._watchForTitleUpdate(topic.id);
188+
}
189+
190+
_watchForTitleUpdate(topicId) {
191+
if (this._subscribedTopicIds?.has(topicId)) {
192+
return;
193+
}
194+
195+
this._subscribedTopicIds = this._subscribedTopicIds || new Set();
196+
this._subscribedTopicIds.add(topicId);
197+
198+
const channel = `/discourse-ai/ai-bot/topic/${topicId}`;
199+
200+
this.messageBus.subscribe(channel, (payload) => {
201+
this._applyTitleUpdate(topicId, payload.title);
202+
this.messageBus.unsubscribe(channel);
203+
});
204+
}
205+
206+
_applyTitleUpdate(topicId, newTitle) {
207+
this.topics = this.topics.map((t) =>
208+
t.id === topicId ? { ...t, title: newTitle } : t
209+
);
210+
211+
this._rebuildSections();
212+
}
213+
214+
// organize by date and create a section for each date group
215+
_rebuildSections() {
216+
const now = Date.now();
217+
const fresh = [];
218+
219+
this.topics.forEach((t) => {
220+
const postedAtMs = new Date(t.last_posted_at || now).valueOf();
221+
const diffDays = Math.floor((now - postedAtMs) / 86400000);
222+
let dateGroup;
223+
224+
if (diffDays <= 1) {
225+
dateGroup = "today";
226+
} else if (diffDays <= 7) {
227+
dateGroup = "last-7-days";
228+
} else if (diffDays <= 30) {
229+
dateGroup = "last-30-days";
230+
} else {
231+
const d = new Date(postedAtMs);
232+
const key = `${d.getFullYear()}-${d.getMonth()}`;
233+
dateGroup = key;
234+
}
235+
236+
let sec = fresh.find((s) => s.name === dateGroup);
237+
if (!sec) {
238+
let title;
239+
switch (dateGroup) {
240+
case "today":
241+
title = i18n("discourse_ai.ai_bot.conversations.today");
242+
break;
243+
case "last-7-days":
244+
title = i18n("discourse_ai.ai_bot.conversations.last_7_days");
245+
break;
246+
case "last-30-days":
247+
title = i18n("discourse_ai.ai_bot.conversations.last_30_days");
248+
break;
249+
default:
250+
title = autoUpdatingRelativeAge(new Date(t.last_posted_at));
251+
}
252+
sec = { name: dateGroup, title, links: new TrackedArray() };
253+
fresh.push(sec);
254+
}
255+
256+
sec.links.push({
257+
key: t.id,
258+
route: "topic.fromParamsNear",
259+
models: [t.slug, t.id, t.last_read_post_number || 0],
260+
title: t.title,
261+
text: t.title,
262+
classNames: `ai-conversation-${t.id}`,
263+
});
264+
});
265+
266+
this.sections = new TrackedArray(fresh);
267+
268+
// register each new section once
269+
for (let sec of fresh) {
270+
if (this._registered.has(sec.name)) {
271+
continue;
272+
}
273+
this._registered.add(sec.name);
274+
275+
this.api.addSidebarSection((BaseCustomSidebarSection) => {
276+
return class extends BaseCustomSidebarSection {
277+
@service("ai-conversations-sidebar-manager") manager;
278+
@service("appEvents") events;
279+
280+
constructor() {
281+
super(...arguments);
282+
scheduleOnce("afterRender", this, this.triggerEvent);
283+
}
284+
285+
triggerEvent() {
286+
this.events.trigger("discourse-ai:conversations-sidebar-updated");
287+
}
288+
289+
get name() {
290+
return sec.name;
291+
}
292+
293+
get title() {
294+
return sec.title;
295+
}
296+
297+
get text() {
298+
return htmlSafe(sec.title);
299+
}
300+
301+
get links() {
302+
return (
303+
this.manager.sections.find((s) => s.name === sec.name)?.links ||
304+
[]
305+
);
306+
}
307+
308+
get emptyStateComponent() {
309+
if (!this.manager.isLoading && this.links.length === 0) {
310+
return AiBotSidebarEmptyState;
311+
}
312+
}
313+
};
314+
}, AI_CONVERSATIONS_PANEL);
315+
}
47316
}
48317
}

0 commit comments

Comments
 (0)