Files
discourse-ai/assets/javascripts/discourse/components/modal/diff-modal.gjs
T
Keegan George 62d746662a FIX: Cleanup properties on closing DiffModal (#1442)
This update ensures that we reset the tracked properties when closing the DiffModal so that the state doesn't leak when triggering the AI suggestions again. We also reset before suggesting new changes, thus if regeneration is called there shouldn't be any leaks either.

No tests in this PR as tests currently not working great due to streaming/animation issues. Will do a broader PR following up with various specs to improve test coverage here.
2025-06-17 13:57:46 -07:00

247 lines
6.8 KiB
Plaintext

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { or } from "truth-helpers";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { escapeExpression } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import DiffStreamer from "../../lib/diff-streamer";
import SmoothStreamer from "../../lib/smooth-streamer";
import AiIndicatorWave from "../ai-indicator-wave";
const CHANNEL = "/discourse-ai/ai-helper/stream_composer_suggestion";
export default class ModalDiffModal extends Component {
@service currentUser;
@service messageBus;
@tracked loading = false;
@tracked finalResult = "";
@tracked escapedSelectedText = escapeExpression(this.args.model.selectedText);
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
@tracked suggestion = "";
@tracked
smoothStreamer = new SmoothStreamer(
() => this.suggestion,
(newValue) => (this.suggestion = newValue)
);
constructor() {
super(...arguments);
this.suggestChanges();
}
get diffResult() {
if (this.loading) {
return this.escapedSelectedText;
}
if (this.diffStreamer.diff?.length > 0) {
return this.diffStreamer.diff;
}
// Prevents flash by showing the
// original text when the diff is empty
return this.escapedSelectedText;
}
get smoothStreamerResult() {
if (this.loading) {
return this.escapedSelectedText;
}
return this.smoothStreamer.renderedText;
}
get isStreaming() {
// diffStreamer stops Streaming when it is finished with a chunk, looking at isDone is safe
// it starts off not done
if (this.args.model.showResultAsDiff) {
return !this.diffStreamer.isDone;
}
return this.smoothStreamer.isStreaming;
}
get primaryBtnLabel() {
return this.loading
? i18n("discourse_ai.ai_helper.context_menu.loading")
: i18n("discourse_ai.ai_helper.context_menu.confirm");
}
get primaryBtnDisabled() {
return this.loading || this.isStreaming;
}
@bind
subscribe() {
this.messageBus.subscribe(CHANNEL, this.updateResult);
}
@bind
cleanup() {
// stop all callbacks so it does not end up streaming pointlessly
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
this.messageBus.unsubscribe(CHANNEL, this.updateResult);
}
@action
updateResult(result) {
this.loading = false;
if (result.done) {
this.finalResult = result.result;
this.loading = false;
}
if (this.args.model.showResultAsDiff) {
this.diffStreamer.updateResult(result, "result");
} else {
this.smoothStreamer.updateResult(result, "result");
}
}
@action
async suggestChanges() {
this.#resetState();
try {
this.loading = true;
return await ajax("/discourse-ai/ai-helper/stream_suggestion", {
method: "POST",
data: {
location: "composer",
mode: this.args.model.mode,
text: this.args.model.selectedText,
custom_prompt: this.args.model.customPromptValue,
force_default_locale: true,
client_id: this.messageBus.clientId,
},
});
} catch (e) {
popupAjaxError(e);
}
}
@action
triggerConfirmChanges() {
this.args.closeModal();
if (this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
this.suggestion
);
}
const finalResult =
this.finalResult?.length > 0
? this.finalResult
: this.diffStreamer.suggestion;
if (this.args.model.showResultAsDiff && finalResult) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
finalResult
);
}
}
@action
cleanupAndClose() {
this.#resetState();
this.loading = false;
this.args.closeModal();
}
#resetState() {
this.suggestion = "";
this.finalResult = "";
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
}
<template>
<DModal
class="composer-ai-helper-modal"
@title={{i18n "discourse_ai.ai_helper.context_menu.changes"}}
@closeModal={{this.cleanupAndClose}}
>
<:body>
<div
{{didInsert this.subscribe}}
{{willDestroy this.cleanup}}
class="text-preview"
>
<div
class={{concatClass
"composer-ai-helper-modal__suggestion"
"streamable-content"
(if this.isStreaming "streaming")
(if @model.showResultAsDiff "inline-diff")
(if this.diffStreamer.isThinking "thinking")
(if this.loading "composer-ai-helper-modal__loading")
}}
>
{{~#if @model.showResultAsDiff~}}
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
{{else}}
{{#if (or this.loading this.smoothStreamer.isStreaming)}}
<CookText
@rawText={{this.smoothStreamerResult}}
class="cooked"
/>
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{~this.escapedSelectedText~}}
</div>
<div class="composer-ai-helper-modal__new-value">
<CookText
@rawText={{this.smoothStreamerResult}}
class="cooked"
/>
</div>
{{/if}}
{{/if}}
</div>
</div>
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@disabled={{this.primaryBtnDisabled}}
@action={{this.triggerConfirmChanges}}
@translatedLabel={{this.primaryBtnLabel}}
>
{{#if this.loading}}
<AiIndicatorWave @loading={{this.loading}} />
{{/if}}
</DButton>
<DButton
class="btn-flat discard"
@action={{this.cleanupAndClose}}
@label="discourse_ai.ai_helper.context_menu.discard"
/>
<DButton
class="regenerate"
@icon="arrows-rotate"
@action={{this.suggestChanges}}
@label="discourse_ai.ai_helper.context_menu.regen"
/>
</:footer>
</DModal>
</template>
}