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

Commit 6a33e51

Browse files
DEV: makes ai menu helper a standalone menu (#1434)
The current menu was rendering inside the post text toolbar (on desktop). This is not ideal as the post text toolbar rendering is conditioned on the presence of text selection, when you click a button on the toolbar, by design of the web browsers you will lose your text selection, making all of this super tricky. This commit makes desktop and mobile behave in the same way by rendering their own menu and capturing the quote state when we render the post text selection toolbar, this allows us to reason a much simpler way about the AI helper. This commit also removes what appears to be an unused file and corrects which was seemingly copy/paste mistakes. :warning: Technical note, this commit is correcting the message bus subscription which amongst other things allows to write specs which are not flaky. However due to the current implementation we have a channel per post, which means we need to serialize on last message bus id per post. We have two possible solutions here: - subscribe at the topic level - refactor the code to be able to use `MessageBus.last_ids` to be able to grab multiple posts at once instead of having to call `MessageBus.last_id` and done one Redis call per post --------- Co-authored-by: Keegan George <[email protected]>
1 parent 37dbd48 commit 6a33e51

File tree

6 files changed

+124
-177
lines changed

6 files changed

+124
-177
lines changed

assets/javascripts/discourse/components/ai-post-helper-menu.gjs

Lines changed: 18 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import concatClass from "discourse/helpers/concat-class";
1414
import { ajax } from "discourse/lib/ajax";
1515
import { popupAjaxError } from "discourse/lib/ajax-error";
1616
import { bind } from "discourse/lib/decorators";
17-
import { withPluginApi } from "discourse/lib/plugin-api";
1817
import { sanitize } from "discourse/lib/text";
1918
import { clipboardCopy } from "discourse/lib/utilities";
2019
import { i18n } from "discourse-i18n";
@@ -44,6 +43,9 @@ export default class AiPostHelperMenu extends Component {
4443
@tracked lastSelectedOption = null;
4544
@tracked isSavingFootnote = false;
4645
@tracked supportsAddFootnote = this.args.data.supportsFastEdit;
46+
@tracked
47+
channel =
48+
`/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
4749

4850
@tracked
4951
smoothStreamer = new SmoothStreamer(
@@ -78,23 +80,6 @@ export default class AiPostHelperMenu extends Component {
7880

7981
@tracked _activeAiRequest = null;
8082

81-
constructor() {
82-
super(...arguments);
83-
84-
withPluginApi((api) => {
85-
api.registerValueTransformer(
86-
"post-text-selection-prevent-close",
87-
({ value }) => {
88-
if (this.menuState === this.MENU_STATES.result) {
89-
return true;
90-
}
91-
92-
return value;
93-
}
94-
);
95-
});
96-
}
97-
9883
get footnoteDisabled() {
9984
return this.streaming || !this.supportsAddFootnote;
10085
}
@@ -167,16 +152,17 @@ export default class AiPostHelperMenu extends Component {
167152

168153
@bind
169154
subscribe() {
170-
const channel = `/discourse-ai/ai-helper/stream_suggestion/${this.args.data.quoteState.postId}`;
171-
this.messageBus.subscribe(channel, this._updateResult);
155+
this.messageBus.subscribe(
156+
this.channel,
157+
(data) => this._updateResult(data),
158+
this.args.data.post
159+
.discourse_ai_helper_stream_suggestion_last_message_bus_id
160+
);
172161
}
173162

174163
@bind
175164
unsubscribe() {
176-
this.messageBus.unsubscribe(
177-
"/discourse-ai/ai-helper/stream_suggestion/*",
178-
this._updateResult
179-
);
165+
this.messageBus.unsubscribe(this.channel, this._updateResult);
180166
}
181167

182168
@bind
@@ -239,7 +225,7 @@ export default class AiPostHelperMenu extends Component {
239225
data: {
240226
location: "post",
241227
mode: option.name,
242-
text: this.args.data.selectedText,
228+
text: this.args.data.quoteState.buffer,
243229
post_id: this.args.data.quoteState.postId,
244230
custom_prompt: this.customPromptValue,
245231
client_id: this.messageBus.clientId,
@@ -292,12 +278,10 @@ export default class AiPostHelperMenu extends Component {
292278

293279
@action
294280
closeMenu() {
295-
if (this.site.mobileView) {
296-
return this.args.close();
297-
}
298-
299-
const menu = this.menu.getByIdentifier("post-text-selection-toolbar");
300-
return menu?.close();
281+
// reset state and close
282+
this.suggestion = "";
283+
this.customPromptValue = "";
284+
return this.args.close();
301285
}
302286

303287
@action
@@ -317,9 +301,9 @@ export default class AiPostHelperMenu extends Component {
317301
const credits = i18n(
318302
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
319303
);
320-
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
304+
const withFootnote = `${this.args.data.quoteState.buffer} ^[${sanitizedSuggestion} (${credits})]`;
321305
const newRaw = result.raw.replace(
322-
this.args.data.selectedText,
306+
this.args.data.quoteState.buffer,
323307
withFootnote
324308
);
325309

@@ -338,7 +322,7 @@ export default class AiPostHelperMenu extends Component {
338322
(and this.site.mobileView (eq this.menuState this.MENU_STATES.options))
339323
}}
340324
<div class="ai-post-helper-menu__selected-text">
341-
{{@data.selectedText}}
325+
{{@data.quoteState.buffer}}
342326
</div>
343327
{{/if}}
344328

assets/javascripts/discourse/connectors/post-text-buttons/ai-post-helper-trigger.gjs

Lines changed: 46 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { tracked } from "@glimmer/tracking";
33
import { action } from "@ember/object";
44
import { service } from "@ember/service";
55
import DButton from "discourse/components/d-button";
6-
import virtualElementFromTextRange from "discourse/lib/virtual-element-from-text-range";
7-
import eq from "truth-helpers/helpers/eq";
6+
import { selectedRange } from "discourse/lib/utilities";
87
import AiPostHelperMenu from "../../components/ai-post-helper-menu";
98
import { showPostAIHelper } from "../../lib/show-ai-helper";
109

@@ -13,27 +12,27 @@ export default class AiPostHelperTrigger extends Component {
1312
return showPostAIHelper(outletArgs, helper);
1413
}
1514

16-
@service site;
1715
@service menu;
1816

19-
@tracked menuState = this.MENU_STATES.triggers;
20-
@tracked showMainButtons = true;
21-
@tracked showAiButtons = true;
2217
@tracked postHighlighted = false;
2318
@tracked currentMenu = this.menu.getByIdentifier(
2419
"post-text-selection-toolbar"
2520
);
2621

27-
MENU_STATES = {
28-
triggers: "TRIGGERS",
29-
options: "OPTIONS",
22+
// capture the state at the moment the toolbar is rendered
23+
// so we ensure change of state (selection change for example)
24+
// is not impacting the menu data
25+
menuData = {
26+
...this.args.outletArgs.data,
27+
quoteState: {
28+
buffer: this.args.outletArgs.data.quoteState.buffer,
29+
opts: this.args.outletArgs.data.quoteState.opts,
30+
postId: this.args.outletArgs.data.quoteState.postId,
31+
},
32+
post: this.args.outletArgs.post,
33+
selectedRange: selectedRange(),
3034
};
3135

32-
willDestroy() {
33-
super.willDestroy(...arguments);
34-
this.removeHighlightedText();
35-
}
36-
3736
highlightSelectedText() {
3837
const postId = this.args.outletArgs.data.quoteState.postId;
3938
const postElement = document.querySelector(
@@ -44,14 +43,7 @@ export default class AiPostHelperTrigger extends Component {
4443
return;
4544
}
4645

47-
this.selectedText = this.args.outletArgs.data.quoteState.buffer;
48-
49-
const selection = window.getSelection();
50-
if (!selection.rangeCount) {
51-
return;
52-
}
53-
54-
const range = selection.getRangeAt(0);
46+
const range = this.menuData.selectedRange;
5547

5648
// Split start/end text nodes at their range boundary
5749
if (
@@ -97,11 +89,10 @@ export default class AiPostHelperTrigger extends Component {
9789
// Replace textNode with highlighted clone
9890
const clone = textNode.cloneNode(true);
9991
highlight.appendChild(clone);
100-
10192
textNode.parentNode.replaceChild(highlight, textNode);
10293
}
10394

104-
selection.removeAllRanges();
95+
window.getSelection().removeAllRanges();
10596
this.postHighlighted = true;
10697
}
10798

@@ -110,16 +101,7 @@ export default class AiPostHelperTrigger extends Component {
110101
return;
111102
}
112103

113-
const postId = this.args.outletArgs.data.quoteState.postId;
114-
const postElement = document.querySelector(
115-
`article[data-post-id='${postId}'] .cooked`
116-
);
117-
118-
if (!postElement) {
119-
return;
120-
}
121-
122-
const highlightedSpans = postElement.querySelectorAll(
104+
const highlightedSpans = document.querySelectorAll(
123105
"span.ai-helper-highlighted-selection"
124106
);
125107

@@ -133,65 +115,40 @@ export default class AiPostHelperTrigger extends Component {
133115

134116
@action
135117
async showAiPostHelperMenu() {
136-
this.highlightSelectedText();
137-
if (this.site.mobileView) {
138-
this.currentMenu.close();
139-
140-
await this.menu.show(virtualElementFromTextRange(), {
141-
identifier: "ai-post-helper-menu",
142-
component: AiPostHelperMenu,
143-
inline: true,
144-
interactive: true,
145-
placement: this.shouldRenderUnder ? "bottom-start" : "top-start",
146-
fallbackPlacements: this.shouldRenderUnder
147-
? ["bottom-end", "top-start"]
148-
: ["bottom-start"],
149-
trapTab: false,
150-
closeOnScroll: false,
151-
modalForMobile: true,
152-
data: this.menuData,
153-
});
154-
}
118+
await this.currentMenu.close();
119+
120+
await this.menu.show(this.currentMenu.trigger, {
121+
identifier: "ai-post-helper-menu",
122+
component: AiPostHelperMenu,
123+
interactive: true,
124+
trapTab: false,
125+
closeOnScroll: false,
126+
modalForMobile: true,
127+
data: this.menuData,
128+
placement: "top-start",
129+
fallbackPlacements: ["bottom-start"],
130+
updateOnScroll: false,
131+
onClose: () => {
132+
this.removeHighlightedText();
133+
},
134+
});
155135

156-
this.showMainButtons = false;
157-
this.menuState = this.MENU_STATES.options;
158-
}
136+
await this.currentMenu.destroy();
159137

160-
get menuData() {
161-
// Streamline of data model to be passed to the component when
162-
// instantiated as a DMenu or a simple component in the template
163-
return {
164-
...this.args.outletArgs.data,
165-
quoteState: {
166-
buffer: this.args.outletArgs.data.quoteState.buffer,
167-
opts: this.args.outletArgs.data.quoteState.opts,
168-
postId: this.args.outletArgs.data.quoteState.postId,
169-
},
170-
post: this.args.outletArgs.post,
171-
selectedText: this.selectedText,
172-
};
138+
this.highlightSelectedText();
173139
}
174140

175141
<template>
176-
{{#if this.showMainButtons}}
177-
{{yield}}
178-
{{/if}}
179-
180-
{{#if this.showAiButtons}}
181-
<div class="ai-post-helper">
182-
{{#if (eq this.menuState this.MENU_STATES.triggers)}}
183-
<DButton
184-
@icon="discourse-sparkles"
185-
@title="discourse_ai.ai_helper.post_options_menu.title"
186-
@label="discourse_ai.ai_helper.post_options_menu.trigger"
187-
@action={{this.showAiPostHelperMenu}}
188-
class="btn-flat ai-post-helper__trigger"
189-
/>
190-
191-
{{else if (eq this.menuState this.MENU_STATES.options)}}
192-
<AiPostHelperMenu @data={{this.menuData}} />
193-
{{/if}}
194-
</div>
195-
{{/if}}
142+
{{yield}}
143+
144+
<div class="ai-post-helper">
145+
<DButton
146+
@icon="discourse-sparkles"
147+
@title="discourse_ai.ai_helper.post_options_menu.title"
148+
@label="discourse_ai.ai_helper.post_options_menu.trigger"
149+
@action={{this.showAiPostHelperMenu}}
150+
class="btn-flat ai-post-helper__trigger"
151+
/>
152+
</div>
196153
</template>
197154
}

assets/javascripts/discourse/lib/virtual-element-from-caret-coords.js

Lines changed: 0 additions & 45 deletions
This file was deleted.

lib/ai_helper/entry_point.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ def inject_into(plugin)
7373
scope.user.in_any_groups?(SiteSetting.ai_auto_image_caption_allowed_groups_map)
7474
end,
7575
) { object.auto_image_caption }
76+
77+
plugin.add_to_serializer(
78+
:post,
79+
:discourse_ai_helper_stream_suggestion_last_message_bus_id,
80+
include_condition: -> { SiteSetting.ai_helper_enabled && scope.authenticated? },
81+
) { MessageBus.last_id("/discourse-ai/ai-helper/stream_suggestion/#{object.id}") }
7682
end
7783
end
7884
end

0 commit comments

Comments
 (0)