Merge pull request #857 from basecamp/fizzy-ask

Fizzy Ask
This commit is contained in:
Stanko Krtalić
2025-08-15 08:40:28 +02:00
committed by GitHub
69 changed files with 3411 additions and 5 deletions
@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "textInput", "submitButton", "clientMessageIdInput" ]
static outlets = [ "conversation--messages" ]
#submittingMessage
connect() {
this.#submittingMessage = false
onNextEventLoopTick(() => this.textInputTarget.focus())
}
submit(event) {
event.preventDefault()
if (!this.#submittingMessage) {
this.#submittingMessage = true
this.#submitMessage()
}
}
submitEnd(even) {
this.#submittingMessage = false
if (event.detail.success) {
this.#reset()
} else {
this.conversationMessagesOutlet.failPendingMessage(this.clientMessageIdInputTarget.value)
}
}
async #submitMessage() {
if (this.#validInput()) {
const messageId = this.#generateMessageId()
await this.conversationMessagesOutlet.insertPendingMessage(messageId, this.#messageText)
await nextFrame()
this.conversationMessagesOutlet.scrollToBottom()
this.clientMessageIdInputTarget.value = messageId
this.element.requestSubmit()
}
}
get #messageText() {
return this.textInputTarget.value
}
#validInput() {
return this.#messageText.trim().length > 0
}
#generateMessageId() {
return Math.random().toString(36).slice(2)
}
#reset() {
this.textInputTarget.value = ""
this.clientMessageIdInputTarget.value = ""
}
}
@@ -0,0 +1,108 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static SCROLL_TRESHOLD = 100
static EMOJI_MATCHER = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+$/gu
static targets = [ "messages", "messageTemplate" ]
static classes = [ "failedMessage", "emojiMessage" ]
connect() {
this.scrollToBottom()
}
failPendingMessage(clientMessageId) {
const message = this.element.querySelector(`#message_${clientMessageId}`)
if (message) {
message.classList.add(this.failedMessageClass)
}
}
async insertPendingMessage(clientMessageId, content) {
const html = this.#pendingMessageHtml({
clientMessageId: clientMessageId,
content: content,
extraClasses: this.#allEmoji(content) ? this.emojiMessageClass : ""
})
this.messagesTarget.insertAdjacentHTML("beforeend", html)
await nextFrame()
this.scrollToBottom()
}
scrollToBottom() {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
}
beforeStreamRender(event) {
const target = event.detail.newStream.getAttribute("target")
if (target === this.messagesTarget.id) {
this.#handleScrollPositionOnTurboStreamRender(event)
}
}
#pendingMessageHtml(data) {
let html = this.messageTemplateTarget.innerHTML
for (const key in data) {
html = html.replaceAll(`$${key}$`, data[key])
}
return html
}
#allEmoji(content) {
return content.match(this.constructor.EMOJI_MATCHER)
}
#handleScrollPositionOnTurboStreamRender(event) {
if (event.detail.newStream.action === "append") {
this.#scrollToBottomOnTurboStreamRender(event)
} else {
this.#preserveScrollPositionOnTurboStreamRender(event)
}
}
#scrollToBottomOnTurboStreamRender(event) {
const render = event.detail.render
event.detail.render = async (streamElement) => {
await render(streamElement)
await nextFrame()
this.scrollToBottom()
}
}
#preserveScrollPositionOnTurboStreamRender(event) {
const render = event.detail.render
event.detail.render = async (streamElement) => {
const previousScrollHeight = this.element.scrollHeight
const previousScrollTop = this.element.scrollTop
const previouslyWasAtBottom = this.#scrolledToBottom
await render(streamElement)
if (previouslyWasAtBottom) {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
} else {
const newScrollHeight = this.element.scrollHeight
const scrollDelta = newScrollHeight - previousScrollHeight
this.element.scrollTop = previousScrollTop + scrollDelta
}
await nextFrame()
}
}
get #scrolledToBottom() {
const scrollBottom = this.element.scrollTop + this.element.clientHeight
const contentBottom = this.element.scrollHeight - this.constructor.SCROLL_THRESHOLD
return scrollBottom >= contentBottom
}
}
@@ -5,9 +5,10 @@ import { marked } from "marked"
import { nextFrame } from "helpers/timing_helpers";
export default class extends Controller {
static targets = [ "input", "form", "output", "confirmation", "recentCommands" ]
static targets = [ "input", "form", "output", "confirmation", "recentCommands", "modalTurboFrame" ]
static classes = [ "error", "confirmation", "help", "output", "busy" ]
static values = { originalInput: String, waitingForConfirmation: Boolean }
static outlets = [ "dialog" ]
connect() {
if (this.waitingForConfirmationValue) { this.focus() }
@@ -141,7 +142,7 @@ export default class extends Controller {
}
#handleJsonResponse(responseJson) {
const { confirmation, message, redirect_to } = responseJson
const { confirmation, message, redirect_to, turbo_frame, url } = responseJson
if (message) {
this.#showOutput(message)
@@ -154,6 +155,10 @@ export default class extends Controller {
if (redirect_to) {
Turbo.visit(redirect_to)
}
if (turbo_frame && url) {
this.#showTurboFrameModal(turbo_frame, url)
}
}
async #requestConfirmation(confirmationPrompt) {
@@ -204,4 +209,11 @@ export default class extends Controller {
#hideOutput(html) {
this.element.classList.remove(this.outputClass)
}
#showTurboFrameModal(name, url) {
this.inputTarget.blur()
this.modalTurboFrameTarget.id = name
this.modalTurboFrameTarget.src = url
this.dialogOutlet.open()
}
}