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

Commit 9cd14b0

Browse files
authored
DEV: Move composer AI helper to toolbar (#796)
Previously we had moved the AI helper from the options menu to a selection menu that appears when selecting text in the composer. This had the benefit of making the AI helper a more discoverable feature. Now that some time has passed and the AI helper is more recognized, we will be moving it back to the composer toolbar. This is better because: - It consistent with other behavior and ways of accessing tools in the composer - It has an improved mobile experience - It reduces unnecessary code and keeps things easier to migrate when we have composer V2. - It allows for easily triggering AI helper for all content by clicking the button instead of having to select everything.
1 parent 5b9add0 commit 9cd14b0

File tree

23 files changed

+389
-1029
lines changed

23 files changed

+389
-1029
lines changed

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

Lines changed: 30 additions & 263 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,22 @@ import Component from "@glimmer/component";
22
import { tracked } from "@glimmer/tracking";
33
import { action } from "@ember/object";
44
import { service } from "@ember/service";
5-
import { modifier } from "ember-modifier";
6-
import { eq } from "truth-helpers";
7-
import DButton from "discourse/components/d-button";
8-
import { ajax } from "discourse/lib/ajax";
9-
import { popupAjaxError } from "discourse/lib/ajax-error";
10-
import { bind } from "discourse-common/utils/decorators";
115
import I18n from "discourse-i18n";
12-
import AiHelperButtonGroup from "../components/ai-helper-button-group";
13-
import AiHelperLoading from "../components/ai-helper-loading";
146
import AiHelperOptionsList from "../components/ai-helper-options-list";
157
import ModalDiffModal from "../components/modal/diff-modal";
168
import ThumbnailSuggestion from "../components/modal/thumbnail-suggestions";
179

1810
export default class AiComposerHelperMenu extends Component {
1911
@service modal;
2012
@service siteSettings;
21-
@service aiComposerHelper;
2213
@service currentUser;
23-
@service capabilities;
14+
@service site;
2415
@tracked newSelectedText;
2516
@tracked diff;
26-
@tracked initialValue = "";
2717
@tracked customPromptValue = "";
28-
@tracked loading = false;
29-
@tracked lastUsedOption = null;
30-
@tracked thumbnailSuggestions = null;
31-
@tracked showThumbnailModal = false;
32-
@tracked lastSelectionRange = null;
33-
MENU_STATES = this.aiComposerHelper.MENU_STATES;
3418
prompts = [];
3519
promptTypes = {};
3620

37-
documentListeners = modifier(() => {
38-
document.addEventListener("keydown", this.onKeyDown, { passive: true });
39-
40-
return () => {
41-
document.removeEventListener("keydown", this.onKeyDown);
42-
};
43-
});
44-
4521
get helperOptions() {
4622
let prompts = this.currentUser?.ai_helper_prompts;
4723

@@ -94,260 +70,51 @@ export default class AiComposerHelperMenu extends Component {
9470
return prompts;
9571
}
9672

97-
get reviewButtons() {
98-
return [
99-
{
100-
icon: "exchange-alt",
101-
label: "discourse_ai.ai_helper.context_menu.view_changes",
102-
action: () =>
103-
this.modal.show(ModalDiffModal, {
104-
model: {
105-
diff: this.diff,
106-
oldValue: this.initialValue,
107-
newValue: this.newSelectedText,
108-
revert: this.undoAiAction,
109-
confirm: () => this.updateMenuState(this.MENU_STATES.resets),
110-
},
111-
}),
112-
classes: "view-changes",
113-
},
114-
{
115-
icon: "undo",
116-
label: "discourse_ai.ai_helper.context_menu.revert",
117-
action: this.undoAiAction,
118-
classes: "revert",
119-
},
120-
{
121-
icon: "check",
122-
label: "discourse_ai.ai_helper.context_menu.confirm",
123-
action: () => this.updateMenuState(this.MENU_STATES.resets),
124-
classes: "confirm",
125-
},
126-
];
127-
}
128-
129-
get resetButtons() {
130-
return [
131-
{
132-
icon: "undo",
133-
label: "discourse_ai.ai_helper.context_menu.undo",
134-
action: this.undoAiAction,
135-
classes: "undo",
136-
},
137-
{
138-
icon: "discourse-sparkles",
139-
label: "discourse_ai.ai_helper.context_menu.regen",
140-
action: () => this.updateSelected(this.lastUsedOption),
141-
classes: "regenerate",
142-
},
143-
];
144-
}
145-
146-
get canCloseMenu() {
147-
if (
148-
document.activeElement ===
149-
document.querySelector(".ai-custom-prompt__input")
150-
) {
151-
return false;
152-
}
153-
154-
if (this.loading && this._activeAiRequest !== null) {
155-
return false;
156-
}
157-
158-
if (this.aiComposerHelper.menuState === this.MENU_STATES.review) {
159-
return false;
160-
}
161-
162-
return true;
163-
}
164-
165-
get isExpanded() {
166-
if (this.aiComposerHelper.menuState === this.MENU_STATES.triggers) {
167-
return "";
168-
}
169-
170-
return "is-expanded";
171-
}
172-
173-
@bind
174-
onKeyDown(event) {
175-
if (event.key === "Escape") {
176-
return this.closeMenu();
177-
}
178-
if (
179-
event.key === "Backspace" &&
180-
this.args.data.selectedText &&
181-
this.aiComposerHelper.menuState === this.MENU_STATES.triggers
182-
) {
183-
return this.closeMenu();
184-
}
185-
}
186-
187-
@action
188-
toggleAiHelperOptions() {
189-
this.updateMenuState(this.MENU_STATES.options);
190-
}
191-
19273
@action
193-
async updateSelected(option) {
194-
this._toggleLoadingState(true);
195-
this.lastUsedOption = option;
196-
this.updateMenuState(this.MENU_STATES.loading);
197-
this.initialValue = this.args.data.selectedText;
198-
this.lastSelectionRange = this.args.data.selectionRange;
199-
200-
try {
201-
this._activeAiRequest = await ajax("/discourse-ai/ai-helper/suggest", {
202-
method: "POST",
203-
data: {
74+
suggestChanges(option) {
75+
if (option.name === "illustrate_post") {
76+
this.modal.show(ThumbnailSuggestion, {
77+
model: {
20478
mode: option.id,
205-
text: this.args.data.selectedText,
206-
custom_prompt: this.customPromptValue,
207-
force_default_locale: true,
79+
selectedText: this.args.data.selectedText,
80+
thumbnails: this.thumbnailSuggestions,
20881
},
20982
});
210-
211-
const data = await this._activeAiRequest;
212-
213-
// resets the values if new suggestion is started:
214-
this.diff = null;
215-
this.newSelectedText = null;
216-
this.thumbnailSuggestions = null;
217-
218-
if (option.name === "illustrate_post") {
219-
this._toggleLoadingState(false);
220-
this.closeMenu();
221-
this.thumbnailSuggestions = data.thumbnails;
222-
this.modal.show(ThumbnailSuggestion, {
223-
model: {
224-
thumbnails: this.thumbnailSuggestions,
225-
},
226-
});
227-
} else {
228-
this._updateSuggestedByAi(data);
229-
}
230-
} catch (error) {
231-
popupAjaxError(error);
232-
} finally {
233-
this._toggleLoadingState(false);
234-
}
235-
236-
return this._activeAiRequest;
237-
}
238-
239-
@action
240-
cancelAiAction() {
241-
if (this._activeAiRequest) {
242-
this._activeAiRequest.abort();
243-
this._activeAiRequest = null;
244-
this._toggleLoadingState(false);
245-
this.closeMenu();
83+
return this.args.close();
24684
}
247-
}
24885

249-
@action
250-
updateMenuState(newState) {
251-
this.aiComposerHelper.menuState = newState;
86+
this.modal.show(ModalDiffModal, {
87+
model: {
88+
mode: option.id,
89+
selectedText: this.args.data.selectedText,
90+
revert: this.undoAiAction,
91+
toolbarEvent: this.args.data.toolbarEvent,
92+
customPromptValue: this.customPromptValue,
93+
},
94+
});
95+
return this.args.close();
25296
}
25397

25498
@action
25599
closeMenu() {
256-
if (!this.canCloseMenu) {
257-
return;
258-
}
259-
260100
this.customPromptValue = "";
261-
this.updateMenuState(this.MENU_STATES.triggers);
262101
this.args.close();
263102
}
264103

265-
@action
266-
undoAiAction() {
267-
if (this.capabilities.isFirefox) {
268-
// execCommand("undo") is no not supported in Firefox so we insert old text at range
269-
// we also need to calculate the length diffrence between the old and new text
270-
const lengthDifference =
271-
this.args.data.selectedText.length - this.initialValue.length;
272-
const end = this.lastSelectionRange.y - lengthDifference;
273-
this._insertAt(this.lastSelectionRange.x, end, this.initialValue);
274-
} else {
275-
document.execCommand("undo", false, null);
276-
}
277-
278-
// context menu is prevented from closing when in review state
279-
// so we change to reset state quickly before closing
280-
this.updateMenuState(this.MENU_STATES.resets);
281-
this.closeMenu();
282-
}
283-
284-
_toggleLoadingState(loading) {
285-
if (loading) {
286-
this.args.data.dEditorInput.classList.add("loading");
287-
return (this.loading = true);
288-
}
289-
290-
this.args.data.dEditorInput.classList.remove("loading");
291-
this.loading = false;
292-
}
293-
294-
_updateSuggestedByAi(data) {
295-
this.newSelectedText = data.suggestions[0];
296-
297-
if (data.diff) {
298-
this.diff = data.diff;
299-
}
300-
301-
this._insertAt(
302-
this.args.data.selectionRange.x,
303-
this.args.data.selectionRange.y,
304-
this.newSelectedText
305-
);
306-
307-
this.updateMenuState(this.MENU_STATES.review);
308-
}
309-
310-
_insertAt(start, end, text) {
311-
this.args.data.dEditorInput.setSelectionRange(start, end);
312-
this.args.data.dEditorInput.focus();
313-
document.execCommand("insertText", false, text);
314-
}
315-
316104
<template>
317-
<div
318-
class="ai-composer-helper-menu {{this.isExpanded}}"
319-
{{this.documentListeners}}
320-
>
321-
{{#if (eq this.aiComposerHelper.menuState this.MENU_STATES.triggers)}}
322-
<ul class="ai-composer-helper-menu__triggers">
323-
<li>
324-
<DButton
325-
@icon="discourse-sparkles"
326-
@label="discourse_ai.ai_helper.context_menu.trigger"
327-
@action={{this.toggleAiHelperOptions}}
328-
class="btn-flat"
329-
/>
330-
</li>
331-
</ul>
332-
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.options)}}
333-
<AiHelperOptionsList
334-
@options={{this.helperOptions}}
335-
@customPromptValue={{this.customPromptValue}}
336-
@performAction={{this.updateSelected}}
337-
/>
338-
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.loading)}}
339-
<AiHelperLoading @cancel={{this.cancelAiAction}} />
340-
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.review)}}
341-
<AiHelperButtonGroup
342-
@buttons={{this.reviewButtons}}
343-
class="ai-composer-helper-menu__review"
344-
/>
345-
{{else if (eq this.aiComposerHelper.menuState this.MENU_STATES.resets)}}
346-
<AiHelperButtonGroup
347-
@buttons={{this.resetButtons}}
348-
class="ai-composer-helper-menu__resets"
349-
/>
105+
<div class="ai-composer-helper-menu">
106+
{{#if this.site.mobileView}}
107+
<div class="ai-composer-helper-menu__selected-text">
108+
{{@data.selectedText}}
109+
</div>
350110
{{/if}}
111+
112+
<AiHelperOptionsList
113+
@options={{this.helperOptions}}
114+
@customPromptValue={{this.customPromptValue}}
115+
@performAction={{this.suggestChanges}}
116+
@shortcutVisible={{true}}
117+
/>
351118
</div>
352119
</template>
353120
}

assets/javascripts/discourse/components/ai-helper-button-group.gjs

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

assets/javascripts/discourse/components/ai-helper-custom-prompt.gjs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { on } from "@ember/modifier";
44
import { action } from "@ember/object";
55
import DButton from "discourse/components/d-button";
66
import withEventValue from "discourse/helpers/with-event-value";
7-
import autoFocus from "discourse/modifiers/auto-focus";
87
import i18n from "discourse-common/helpers/i18n";
98
import not from "truth-helpers/helpers/not";
109

@@ -29,12 +28,7 @@ export default class AiHelperCustomPrompt extends Component {
2928
}}
3029
class="ai-custom-prompt__input"
3130
type="text"
32-
{{!-- Using {{autoFocus}} helper instead of built in autofocus="autofocus"
33-
because built in autofocus doesn't work consistently when component is
34-
invoked twice separetly without a page refresh.
35-
(i.e. trigger in post AI helper followed by trigger in composer AI helper)
36-
--}}
37-
{{autoFocus}}
31+
autofocus="autofocus"
3832
/>
3933

4034
<DButton

0 commit comments

Comments
 (0)