UX: move topic summary from DMenu to DModal (#992)

Co-authored-by: Keegan George <kgeorge13@gmail.com>
This commit is contained in:
Kris 2024-12-03 13:30:15 -05:00 committed by GitHub
parent ce6a2eca21
commit 8203bdfbc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 171 additions and 178 deletions

View File

@ -7,26 +7,29 @@ import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { cancel, later } from "@ember/runloop"; import { cancel, later } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { not } from "truth-helpers";
import CookText from "discourse/components/cook-text"; import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class"; import concatClass from "discourse/helpers/concat-class";
import htmlClass from "discourse/helpers/html-class";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { shortDateNoYear } from "discourse/lib/formatter"; import { shortDateNoYear } from "discourse/lib/formatter";
import dIcon from "discourse-common/helpers/d-icon"; import dIcon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n"; import i18n from "discourse-common/helpers/i18n";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
import DTooltip from "float-kit/components/d-tooltip"; import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton"; import AiSummarySkeleton from "../../components/ai-summary-skeleton";
const STREAMED_TEXT_SPEED = 15; const STREAMED_TEXT_SPEED = 15;
export default class AiSummaryBox extends Component { export default class AiSummaryModal extends Component {
@service siteSettings; @service siteSettings;
@service messageBus; @service messageBus;
@service currentUser; @service currentUser;
@service site; @service site;
@service modal;
@tracked text = ""; @tracked text = "";
@tracked summarizedOn = null; @tracked summarizedOn = null;
@ -68,11 +71,11 @@ export default class AiSummaryBox extends Component {
} }
get topRepliesSummaryEnabled() { get topRepliesSummaryEnabled() {
return this.args.outletArgs.postStream.summary; return this.args.model.postStream.summary;
} }
get topicId() { get topicId() {
return this.args.outletArgs.topic.id; return this.args.model.topic.id;
} }
get baseSummarizationURL() { get baseSummarizationURL() {
@ -80,13 +83,8 @@ export default class AiSummaryBox extends Component {
} }
@bind @bind
subscribe(unsubscribe, [topicId]) { subscribe() {
const sameTopicId = this.args.outletArgs.topic.id === topicId; const channel = `/discourse-ai/summaries/topic/${this.args.model.topic.id}`;
if (unsubscribe && this._channel && !sameTopicId) {
this.unsubscribe();
}
const channel = `/discourse-ai/summaries/topic/${this.args.outletArgs.topic.id}`;
this._channel = channel; this._channel = channel;
this.messageBus.subscribe(channel, this._updateSummary); this.messageBus.subscribe(channel, this._updateSummary);
} }
@ -206,100 +204,66 @@ export default class AiSummaryBox extends Component {
} }
@action @action
async onClose() { handleClose() {
await this.dMenu.close(); this.modal.triggerElement = null; // prevent refocus of trigger, which changes scroll position
this.unsubscribe(); this.args.closeModal();
} }
<template> <template>
{{#if @outletArgs.topic.summarizable}} <DModal
<div @title={{i18n "discourse_ai.summarization.topic.title"}}
class="ai-summarization-button" @closeModal={{this.handleClose}}
{{didInsert this.subscribe}} @bodyClass="ai-summary-modal__body"
{{didUpdate this.subscribe @outletArgs.topic.id}} class="ai-summary-modal"
{{willDestroy this.unsubscribe}} {{didInsert this.subscribe @model.topic.id}}
> {{didUpdate this.subscribe @model.topic.id}}
<DMenu {{willDestroy this.unsubscribe}}
@onShow={{this.generateSummary}} @hideFooter={{not this.summarizedOn}}
@arrow={{false}} >
@identifier="topic-map__ai-summary" <:body>
@onRegisterApi={{this.onRegisterApi}} {{htmlClass "scrollable-modal"}}
@interactive={{true}} <div class="ai-summary-container" {{didInsert this.generateSummary}}>
@triggers="click" <article
@placement="left" class={{concatClass
@modalForMobile={{true}} "ai-summary-box"
@groupIdentifier="topic-map" "streamable-content"
@inline={{true}} (if this.isStreaming "streaming")
@label={{i18n "summary.buttons.generate"}} }}
@title={{i18n "summary.buttons.generate"}} >
@icon="discourse-sparkles" {{#if this.loading}}
@triggerClass="ai-topic-summarization" <AiSummarySkeleton />
@closeOnClickOutside={{false}} {{else}}
> <div class="generated-summary cooked">
<:content> <CookText @rawText={{this.streamedText}} />
<div class="ai-summary-container"> </div>
<header class="ai-summary__header"> {{/if}}
<h3>{{i18n "discourse_ai.summarization.topic.title"}}</h3> </article>
{{#if this.site.desktopView}} </div>
<DButton </:body>
@title="discourse_ai.summarization.topic.close" <:footer>
@action={{this.onClose}} <p class="summarized-on">
@icon="times" {{i18n "summary.summarized_on" date=this.summarizedOn}}
class="btn-transparent ai-summary__close" <DTooltip @placements={{array "top-end"}}>
/> <:trigger>
{{/if}} {{dIcon "circle-info"}}
</header> </:trigger>
<:content>
<article {{i18n "summary.model_used" model=this.summarizedBy}}
class={{concatClass </:content>
"ai-summary-box" </DTooltip>
"streamable-content" </p>
(if this.isStreaming "streaming") {{#if this.outdated}}
}} <p class="summary-outdated">{{this.outdatedSummaryWarningText}}</p>
> {{/if}}
{{#if this.loading}} {{#if this.canRegenerate}}
<AiSummarySkeleton /> <DButton
{{else}} @label="summary.buttons.regenerate"
<div class="generated-summary cooked"> @title="summary.buttons.regenerate"
<CookText @rawText={{this.streamedText}} /> @action={{this.regenerateSummary}}
</div> @icon="sync"
{{#if this.summarizedOn}} />
<div class="summarized-on"> {{/if}}
<p> </:footer>
{{i18n "summary.summarized_on" date=this.summarizedOn}} </DModal>
<DTooltip @placements={{array "top-end"}}>
<:trigger>
{{dIcon "info-circle"}}
</:trigger>
<:content>
{{i18n
"summary.model_used"
model=this.summarizedBy
}}
</:content>
</DTooltip>
</p>
<div class="outdated-summary">
{{#if this.outdated}}
<p>{{this.outdatedSummaryWarningText}}</p>
{{/if}}
{{#if this.canRegenerate}}
<DButton
@label="summary.buttons.regenerate"
@title="summary.buttons.regenerate"
@action={{this.regenerateSummary}}
@icon="sync"
/>
{{/if}}
</div>
</div>
{{/if}}
{{/if}}
</article>
</div>
</:content>
</DMenu>
</div>
{{/if}}
</template> </template>
} }

View File

@ -0,0 +1,30 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import AiSummaryModal from "../../components/modal/ai-summary-modal";
export default class AiSummaryTrigger extends Component {
@service modal;
@action
openAiSummaryModal() {
this.modal.show(AiSummaryModal, {
model: {
topic: this.args.outletArgs.topic,
postStream: this.args.outletArgs.postStream,
},
});
}
<template>
{{#if @outletArgs.topic.summarizable}}
<DButton
@label="summary.buttons.generate"
@icon="discourse-sparkles"
@action={{this.openAiSummaryModal}}
class="ai-summarization-button"
/>
{{/if}}
</template>
}

View File

@ -24,25 +24,9 @@
} }
} }
} }
.topic-map__additional-contents {
.ai-summarization-button {
padding-block: 0.5em;
display: flex;
gap: 0.5em;
button span {
white-space: nowrap;
}
}
}
} }
.topic-map__ai-summary-content { .ai-summary-modal {
.ai-summary-container {
width: 100vw;
}
.ai-summary { .ai-summary {
&__list { &__list {
list-style: none; list-style: none;
@ -174,14 +158,6 @@
margin: 0; margin: 0;
} }
.summarized-on p {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.25em;
margin-bottom: 0;
}
.outdated-summary { .outdated-summary {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -193,6 +169,40 @@
color: var(--primary-medium); color: var(--primary-medium);
} }
} }
.d-modal__footer {
display: grid;
gap: 0;
grid-template-areas: "summarized regenerate" " outdated regenerate";
grid-template-columns: 1fr auto;
@include breakpoint(mobile-large) {
gap: 0.25em 0.5em;
grid-template-areas: "summarized summarized" "regenerate outdated";
}
p {
margin: 0;
}
.fk-d-tooltip__trigger {
vertical-align: text-top;
}
.summary-outdated {
color: var(--primary-high);
font-size: var(--font-down-1);
line-height: var(--line-height-medium);
}
.summarized-on {
grid-area: summarized;
}
button {
grid-area: regenerate;
justify-self: start;
}
}
} }
@keyframes appear { @keyframes appear {

View File

@ -1,39 +1,27 @@
.topic-map { html.scrollable-modal {
.ai-summarization-button { overflow: auto; // overrides core .modal-open class scroll lock
.fk-d-menu { }
position: fixed;
top: calc(var(--header-offset) + 1rem) !important;
left: unset !important;
right: 1rem !important;
width: 470px;
max-width: 470px !important; //overruling JS
max-height: calc(
100vh - var(--header-offset) - 3rem - var(--composer-height, 0px)
);
.ai-summary__header, .ai-summary-modal {
.ai-summary-box { .d-modal__container {
padding: 0.75em 1rem; position: fixed;
box-sizing: border-box; top: var(--header-offset);
} margin-top: 1em;
right: 1em;
width: 100vw;
max-width: 30em;
max-height: calc(
100vh - var(--header-offset) - 3rem - var(--composer-height, 0px)
);
.ai-summary { box-shadow: var(--shadow-menu-panel);
&__header { }
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding-block: 0.5rem;
padding-right: 0.5rem;
background: var(--secondary);
border-bottom: 1px solid var(--primary-low);
h3 { .fullscreen-composer & {
margin: 0; display: none;
}
}
}
}
} }
} }
.ai-summary-modal + .d-modal__backdrop {
display: none;
}

View File

@ -1,3 +0,0 @@
.ai-summary-box {
padding: 0.75em 1rem;
}

View File

@ -20,7 +20,6 @@ register_asset "stylesheets/modules/ai-helper/common/ai-helper.scss"
register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop register_asset "stylesheets/modules/ai-helper/desktop/ai-helper-fk-modals.scss", :desktop
register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile register_asset "stylesheets/modules/ai-helper/mobile/ai-helper.scss", :mobile
register_asset "stylesheets/modules/summarization/mobile/ai-summary.scss", :mobile
register_asset "stylesheets/modules/summarization/common/ai-summary.scss" register_asset "stylesheets/modules/summarization/common/ai-summary.scss"
register_asset "stylesheets/modules/summarization/desktop/ai-summary.scss", :desktop register_asset "stylesheets/modules/summarization/desktop/ai-summary.scss", :desktop

View File

@ -2,16 +2,16 @@
module PageObjects module PageObjects
module Components module Components
class AiSummaryBox < PageObjects::Components::Base class AiSummaryTrigger < PageObjects::Components::Base
SUMMARY_BUTTON_SELECTOR = ".ai-summarization-button button" SUMMARY_BUTTON_SELECTOR = ".ai-summarization-button"
SUMMARY_CONTAINER_SELECTOR = ".ai-summary-container" SUMMARY_CONTAINER_SELECTOR = ".ai-summary-modal"
def click_summarize def click_summarize
find(SUMMARY_BUTTON_SELECTOR).click find(SUMMARY_BUTTON_SELECTOR).click
end end
def click_regenerate_summary def click_regenerate_summary
find("#{SUMMARY_CONTAINER_SELECTOR} .outdated-summary button").click find("#{SUMMARY_CONTAINER_SELECTOR} .d-modal__footer button").click
end end
def has_summary?(summary) def has_summary?(summary)

View File

@ -14,7 +14,7 @@ RSpec.describe "Summarize a topic ", type: :system do
end end
let(:summarization_result) { "This is a summary" } let(:summarization_result) { "This is a summary" }
let(:topic_page) { PageObjects::Pages::Topic.new } let(:topic_page) { PageObjects::Pages::Topic.new }
let(:summary_box) { PageObjects::Components::AiSummaryBox.new } let(:summary_box) { PageObjects::Components::AiSummaryTrigger.new }
before do before do
group.add(current_user) group.add(current_user)

View File

@ -22,7 +22,12 @@ acceptance("Topic - Summary", function (needs) {
}); });
server.get("/discourse-ai/summarization/t/1", () => { server.get("/discourse-ai/summarization/t/1", () => {
return helper.response({}); return helper.response({
ai_topic_summary: {
summarized_text: "This a",
},
done: false,
});
}); });
}); });
@ -39,7 +44,7 @@ acceptance("Topic - Summary", function (needs) {
ai_topic_summary: { summarized_text: partialSummary }, ai_topic_summary: { summarized_text: partialSummary },
}); });
await click(".ai-topic-summarization"); await click(".ai-summarization-button");
assert assert
.dom(".ai-summary-box .generated-summary p") .dom(".ai-summary-box .generated-summary p")
@ -63,7 +68,7 @@ acceptance("Topic - Summary", function (needs) {
.hasText(finalSummary, "Updates the summary with a final result"); .hasText(finalSummary, "Updates the summary with a final result");
assert assert
.dom(".ai-summary-box .summarized-on") .dom(".ai-summary-modal .summarized-on")
.exists("summary metadata exists"); .exists("summary metadata exists");
}); });
@ -76,7 +81,7 @@ acceptance("Topic - Summary", function (needs) {
ai_topic_summary: { summarized_text: partialSummary }, ai_topic_summary: { summarized_text: partialSummary },
}); });
await click(".ai-topic-summarization"); await click(".ai-summarization-button");
const finalSummaryCooked = const finalSummaryCooked =
"In this post, <a href='/t/-/1/1'>bianca</a> said some stuff."; "In this post, <a href='/t/-/1/1'>bianca</a> said some stuff.";
const finalSummaryResult = "In this post, bianca said some stuff."; const finalSummaryResult = "In this post, bianca said some stuff.";
@ -128,20 +133,20 @@ acceptance("Topic - Summary - Anon", function (needs) {
test("displays cached summary immediately", async function (assert) { test("displays cached summary immediately", async function (assert) {
await visit("/t/-/1"); await visit("/t/-/1");
await click(".ai-topic-summarization"); await click(".ai-summarization-button");
assert assert
.dom(".ai-summary-box .generated-summary p") .dom(".ai-summary-box .generated-summary p")
.hasText(finalSummary, "Updates the summary with the result"); .hasText(finalSummary, "Updates the summary with the result");
assert assert
.dom(".ai-summary-box .summarized-on") .dom(".ai-summary-modal .summarized-on")
.exists("summary metadata exists"); .exists("summary metadata exists");
}); });
test("clicking outside of summary should not close the summary box", async function (assert) { test("clicking outside of summary should not close the summary box", async function (assert) {
await visit("/t/-/1"); await visit("/t/-/1");
await click(".ai-topic-summarization"); await click(".ai-summarization-button");
await click("#main-outlet-wrapper"); await click("#main-outlet-wrapper");
assert.dom(".ai-summary-box").exists(); assert.dom(".ai-summary-box").exists();
}); });