diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/animation.css index 5a90f679c..8c617a1d7 100644 --- a/app/assets/stylesheets/animation.css +++ b/app/assets/stylesheets/animation.css @@ -49,6 +49,18 @@ to { transform: translateY(2rem); } } + @keyframes submitting { + 0% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% } + 12.5% { -webkit-mask-position: 0% 50%, 50% 0%, 100% 0% } + 25% { -webkit-mask-position: 0% 100%, 50% 50%, 100% 0% } + 37.5% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 50% } + 50% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 100% } + 62.5% { -webkit-mask-position: 0% 50%, 50% 100%, 100% 100% } + 75% { -webkit-mask-position: 0% 0%, 50% 50%, 100% 100% } + 87.5% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 50% } + 100% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% } + } + @keyframes success { 0% { background-color: var(--color-border-darker); scale: 0.8; } 33% { background-color: var(--color-border-darker); scale: 1; } diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css new file mode 100644 index 000000000..de71dbbb4 --- /dev/null +++ b/app/assets/stylesheets/bar.css @@ -0,0 +1,52 @@ +@layer components { + .bar { + --row-gap: 0.2lh; + + background-color: var(--color-terminal-bg); + color: var(--color-terminal-text); + display: flex; + flex-direction: column; + font-size: 0.9em; + inset: auto 0 0 0; + max-block-size: 100%; + padding: var(--block-space) calc(var(--tray-size) + calc(var(--inline-space) * 3)) calc(var(--block-space) + env(safe-area-inset-bottom)); + place-content: center; + position: fixed; + z-index: var(--z-terminal); + + @media (prefers-color-scheme: dark) { + border-block: 1px solid var(--color-ink-lighter); + } + } + + .bar__input { + transform: translateX(50%); + transition: transform 350ms cubic-bezier(0.25, 1.25, 0.5, 1); + + .bar:has(.bar__placeholder[hidden]) & { + transform: translateX(0); + } + } + + .bar__modal { + background-color: var(--color-terminal-bg); + block-size: 75dvh; + border-block: 1px solid var(--color-ink-lighter); + inline-size: 100vw; + inset: auto 0 0 0; + max-inline-size: 100vw; + margin-block-end: calc(var(--footer-height) - 0.3rem); + position: fixed; + z-index: -1; + } + + .bar__placeholder { + font-weight: 500; + opacity: 0.66; + + .btn--plain { + font-weight: 500; + white-space: nowrap; + } + } +} diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index a78431a18..7bedc3334 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -460,36 +460,4 @@ margin-block-end: 0; } } - - .card--search { - --card-color: var(--color-ink); - --card-padding-block: 0.75rem; - --card-padding-inline: 0.75rem; - - background-color: var(--color-canvas); - - &:not(&:hover) { - box-shadow: 0 0 0 1px var(--color-ink-lighter); - } - - .card__collection { - background-color: var(--color-canvas); - color: var(--color-ink-medium); - font-size: var(--text-x-small); - font-weight: 600; - padding-block-end: 1ch; - } - - .card__id { - margin: 0; - } - - .card__body { - padding: 0; - } - - .card__title { - font-size: var(--text-medium); - } - } } diff --git a/app/assets/stylesheets/conversation.css b/app/assets/stylesheets/conversation.css index cec3cf267..0ee03fa59 100644 --- a/app/assets/stylesheets/conversation.css +++ b/app/assets/stylesheets/conversation.css @@ -1,52 +1,27 @@ @layer components { - .conversation { - font-family: var(--font-sans); - padding-block-end: 6em; - } - - .conversation__composer { - backdrop-filter: blur(24px); - display: flex; - gap: var(--inline-space); - inset: auto 0 0 0; - padding: var(--block-space); - position: absolute; - - .conversation__submit { - display: flex; - flex-direction: column; - } - } - .conversation__header { align-items: center; - backdrop-filter: blur(24px); display: flex; inset: 0 0 auto 0; - justify-content: space-between; - padding: var(--block-space); + justify-content: flex-end; + padding: var(--block-space-half) var(--block-space); position: absolute; } - .conversation__input { - align-items: center; - flex-grow: 1; + .conversation__composer-input.input { + --focus-ring-size: 0; + --input-border-color: var(--color-ink-light); + --input-border-radius: 0; + --input-padding: 0.1em; - .input--textarea { - --input-padding: 0.5em !important; - } - - .conversation:has(.conversation__thinking-indicator--thinking) & { - animation: pulse 1.5s infinite; - pointer-events: none; - } + border-width: 0 0 1px; + max-inline-size: 50ch; } .conversation__message { display: flex; .action-text-content { - border-radius: 0.5rem; inline-size: fit-content; } @@ -62,6 +37,19 @@ } } + .conversation__messages { + --gap: 4vmin; + + display: flex; + flex-direction: column; + gap: var(--gap); + margin-inline: auto; + max-block-size: 75dvh; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--block-space) calc(var(--block-space-double) + var(--btn-size)); + } + .conversation__message--assistant { justify-content: flex-start; @@ -83,20 +71,32 @@ justify-content: flex-end; .action-text-content { - background-color: var(--color-ink-lightest); + background-color: var(--color-selected); margin-inline-start: 15%; padding: var(--block-space-half) var(--inline-space-double); text-align: end; } } + .conversation__thinking-indicator { + inset: auto auto var(--footer-height) 50%; + position: absolute; + transform: translateX(-50%); + + .spinner { + display: none; + + .conversation__thinking-indicator--thinking & { + display: block; + } + } + } + .conversation__transcript { display: flex; flex-direction: column; gap: var(--block-space); - margin-inline: calc(var(--block-space-half) * -0.5); - max-block-size: calc(100dvh - 6em - var(--block-space-double) - 2 * var(--footer-height)); - overflow-y: auto; - padding: var(--block-space-half) var(--block-space); + margin-inline: auto; + max-inline-size: 67ch; } } diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index f51556daa..d0fad3d64 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -1,51 +1,37 @@ @layer components { .search { --gap: 4vmin; + --width: 80ch; - display: grid; + display: flex; + flex-direction: column; gap: var(--gap); - max-inline-size: 960px; margin-inline: auto; - - @media (min-width: 640px) { - grid-template-columns: 2fr 1fr; - } + max-block-size: 100%; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--block-space) calc(var(--block-space-double) + var(--btn-size)); } /* Form /* ------------------------------------------------------------------------ */ - .search__form { - --search-form-padding: 1ch; - --search-btn-size: 2rem; - - background-color: var(--color-canvas); - inset-block-start: 0; - grid-column: 1 / -1; - margin-inline: calc(-1 * var(--search-form-padding)); - padding: var(--search-form-padding); - position: sticky; - z-index: 2; - } - - .search__form-icon { - border-radius: 0; - inset: 50% auto auto calc(var(--search-form-padding) * 2); - opacity: 0.66; - position: absolute; - translate: 0 -50%; - } - .search__form-input { - padding-inline: var(--search-btn-size); - position: relative; + --clear-icon-size: 1em; + --focus-ring-size: 0; + --input-border-color: var(--color-ink-light); + --input-border-radius: 0; + --input-padding: 0.1em; + + border-width: 0 0 1px; + max-inline-size: 50ch; &::-webkit-search-cancel-button { --clear-icon-size: 0.75rem; -webkit-appearance: none; appearance: none; - background-image: url('data:image/svg+xml;utf8,'); + background-image: url('data:image/svg+xml;utf8,'); background-repeat: no-repeat; background-size: var(--clear-icon-size); block-size: var(--clear-icon-size); @@ -54,58 +40,44 @@ inset: 50% 1ch auto auto; position: absolute; translate: 0 -50%; + + @media (prefers-color-scheme: dark) { + background-image: url('data:image/svg+xml;utf8,'); + } } } /* Content /* ------------------------------------------------------------------------ */ + .search__header { + align-items: center; + display: flex; + inset: 0 0 auto 0; + justify-content: flex-end; + padding: var(--block-space-half) var(--block-space); + position: absolute; + } + .search__list { display: grid; - gap: 1ch; + gap: var(--block-space); list-style: none; - margin: 0; + margin: 0 auto; + max-inline-size: 80ch; padding: 0; text-align: start; } - .search__results { - /* Full-width when there isn't a search history */ - @media (min-width: 640px) { - &:not(:has(+ .search__history li)) { - grid-column: 1 / -1; - } - } - } - .search__empty { + margin-block: 3em; opacity: 0.66; - border: 2px dashed var(--color-ink-light); - border-radius: 1ch; - padding: calc(var(--gap) / 2); - } - - .search__history { - align-self: start; - background-color: var(--color-ink-lightest); - border-radius: 0.5em; - display: none; - flex-direction: column; - gap: 1ch; - padding: 1em; - text-align: start; - - /* Only show on larger screens when there's a search history to show */ - @media (min-width: 640px) { - &:has(li) { - display: flex; - } - } + text-align: center; } .search__excerpt { + color: var(--color-ink); font-size: var(--text-small); - margin-block-start: 0.5ch; } .search__excerpt--comment { @@ -113,15 +85,28 @@ --comment-avatar-size: 32px; --padding: 1ch; + align-items: center; background-color: var(--color-ink-lightest); border-radius: 1ch; display: flex; gap: 1ch; - margin-left: calc(var(--comment-avatar-size) / 2); + margin-inline-start: calc(var(--comment-avatar-size) / 2); padding-block: 0.5ch; .avatar { - margin-left: calc(-0.5 * var(--comment-avatar-size)); + margin-inline-start: calc(-0.5 * var(--comment-avatar-size)); } } + + .search__result { + color: var(--color-link); + + &:not(&:hover) { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + + .search__title { + text-decoration: underline; + } } diff --git a/app/assets/stylesheets/terminals.css b/app/assets/stylesheets/terminals.css deleted file mode 100644 index 683642ba5..000000000 --- a/app/assets/stylesheets/terminals.css +++ /dev/null @@ -1,260 +0,0 @@ -@layer components { - .terminal { - --row-gap: 0.2lh; - - background-color: var(--color-terminal-bg); - color: var(--color-terminal-text); - font-family: var(--font-mono); - font-size: 0.9em; - inset: auto 0 0 0; - max-block-size: 100%; - padding: var(--block-space) calc(var(--tray-size) + calc(var(--inline-space) * 3)) calc(var(--block-space) + env(safe-area-inset-bottom)); - position: fixed; - z-index: var(--z-terminal); - - @media (prefers-color-scheme: dark) { - border-block-start: 1px solid var(--color-ink-lighter); - } - } - - .terminal__button { - --btn-background: var(--color-terminal-bg); - --btn-color: var(--color-terminal-text); - --btn-border-radius: 0; - --btn-border-color: var(--color-terminal-text); - --btn-padding: 0.1em 0.7em; - --btn-border-size: 1px; - - font-family: var(--font-mono); - font-size: var(--text-x-small); - white-space: nowrap; - - &:where(:not(:active)):focus-visible { - outline: 2px solid var(--color-terminal-text); - } - } - - .terminal__command { - --btn-background: transparent; - --btn-color: var(--color-terminal-text); - - font-weight: normal; - overflow: hidden; - transition: unset; - - span:first-letter { - text-transform: capitalize; - } - } - - .terminal__container { - max-block-size: calc(100vh - var(--btn-size) - var(--block-space)); - overflow: auto; - } - - .terminal__help { - background: var(--color-terminal-bg); - border-radius: 1em; - display: none; - - inline-size: auto; - inset: 50% auto auto 50%; - max-inline-size: 90vw; - - padding: calc(var(--block-space) * 1.5) var(--block-space-double); - position: fixed; - transform: translate(-50%, -50%); - - .terminal--showing-help &, - .terminal--open .terminal__menu:empty ~ & { - display: block; - } - - h2 { - border-block-end: 1px dashed; - font-size: var(--text-normal); - font-weight: normal; - margin-block: 0 0.5lh; - padding-block-end: 0.5lh; - } - - h3 { - font-size: var(--text-small); - margin-block: 0; - text-transform: uppercase; - } - - dl { - display: grid; - grid-template-columns: min-content 1fr; - gap: var(--inline-space); - font-size: var(--text-small); - margin: 0; - } - - dt { - grid-column: 1; - margin-inline-start: var(--inline-space-double); - white-space: nowrap; - } - - dd { - grid-column: 2; - white-space: nowrap; - } - } - - .terminal__input { - --input-border-color: transparent; - --input-border-radius: 0.3em; - --input-color: var(--color-terminal-text); - - accent-color: var(--color-terminal-text); - caret-color: var(--color-terminal-text); - font-family: inherit; - font-weight: 500; - text-align: start; - - &.lexical-editor--empty { - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - - &:focus-within { - text-align: start; - - .lexical-editor__content::before { - inset-inline-start: 0; - translate: 0 -50%; - } - } - - .lexical-editor__content::before { - color: var(--color-ink); - inset: 50% auto auto 50%; - opacity: 0.5; - overflow: hidden; - text-overflow: ellipsis; - transition: 200ms var(--ease-out-expo); - transition-property: inset-inline-start, translate; - translate: -50% -50%; - white-space: nowrap; - - @starting-style { - inset: 0 0 0 50%; - transform: translateX(-50%); - } - } - } - - .terminal--busy:is(:not(.terminal--confirmation)) & { - animation: pulse 1.5s infinite; - } - - .terminal--confirmation & { - color: var(--color-terminal-text-light); - } - - .terminal--error & { - accent-color: var(--color-negative); - caret-color: var(--color-negative); - color: var(--color-negative); - } - - .lexical-editor__content { - min-block-size: initial; - margin: 0; - - b, strong, i, em { - font-weight: inherit; - font-style: inherit; - } - - ul, li { - list-style: none; - margin: 0; - padding: 0; - } - } - } - - .terminal__menu { - display: none; - list-style: none; - - .terminal--open & { - display: flex; - } - - .terminal--showing-help & { - display: none; - } - } - - .terminal__modal { - --panel-border-radius: 1.5em; - --panel-padding: var(--block-space-half); - - max-block-size: calc(100dvh - var(--block-space-double) - 2 * var(--footer-height)); - overflow: clip; - } - - .terminal__item { - &[aria-selected] { - --btn-color: var(--color-terminal-bg); - - background: var(--color-terminal-text); - color: var(--color-terminal-bg); - outline: 2px solid var(--color-terminal-text); - - .terminal__command { - color: var(--color-terminal-bg); - } - } - - @media (any-hover: hover) { - &:where(:not(:active):hover) { - background: color-mix(in srgb, var(--color-terminal-text) 20%, var(--color-terminal-bg)); - } - } - } - - .terminal__output { - display: none; - overflow-y: auto; - padding-inline: var(--inline-space); - - .terminal--confirmation & { - color: var(--color-terminal-text-light); - } - - .terminal--error & { - color: var(--color-negative); - } - - .terminal--showing-output & { - display: block; - } - - p { - + ul { - margin-block-start: 0; - } - - &:has(+ ul) { - margin-block-end: 0; - } - } - } - - /* Lexical prompt insertions - /* ------------------------------------------------------------------------ */ - - .terminal { - action-text-attachment:not(.node--selected) { - --attachment-bg-color: oklch(var(--lch-white) / 15%); - --attachment-text-color: var(--color-terminal-text); - } - } -} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 760e639dd..f501bc956 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -14,6 +14,7 @@ inset-block: auto env(safe-area-inset-bottom); inline-size: var(--tray-size); position: fixed; + transition: inset var(--tray-duration) var(--tray-timing-function); z-index: var(--z-tray); /* Make the dialog, expander, and actions inhabit the same space */ @@ -330,6 +331,10 @@ .tray--pins { inset-inline: var(--tray-margin) auto; + #footer:has(.bar__placeholder[hidden]) & { + inset-inline-start: -100%; + } + .tray__item { --tray-item-z: calc(10 - var(--tray-item-index)); @@ -464,6 +469,10 @@ .tray--notifications { inset-inline: auto var(--tray-margin); + #footer:has(.bar__placeholder[hidden]) & { + inset-inline-end: -100%; + } + .tray__item, [data-navigable-list-target~=item] { [open] &[aria-selected] { diff --git a/app/controllers/commands_controller.rb b/app/controllers/commands_controller.rb deleted file mode 100644 index 43af3c762..000000000 --- a/app/controllers/commands_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -class CommandsController < ApplicationController - def create - command = parse_command(params[:command]) - result = command.execute - respond_with_execution_result(result) - end - - private - def parse_command(string) - command_parser.parse(string) - end - - def command_parser - @command_parser ||= Command::Parser.new(user: Current.user, script_name: request.script_name) - end - - def respond_with_execution_result(result) - # This saves unnecessary back and forth between server and client (e.g: to redirect)- - result = result.is_a?(Array) && result.one? ? result.first : result - - case result - when Command::Result::Redirection - redirect_to result.url - when Command::Result::ShowModal - respond_with_turbo_frame_modal(result.turbo_frame, result.url) - else - redirect_back_or_to root_path - end - end - - def respond_with_turbo_frame_modal(turbo_frame, url) - render json: { turbo_frame: turbo_frame, url: url }, status: :accepted - end -end diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb index cf18e1281..0193539ac 100644 --- a/app/controllers/conversations_controller.rb +++ b/app/controllers/conversations_controller.rb @@ -1,4 +1,8 @@ class ConversationsController < ApplicationController + def create + Current.user.start_or_continue_conversation + end + def show @conversation = Current.user.conversation end diff --git a/app/javascript/controllers/bar_controller.js b/app/javascript/controllers/bar_controller.js new file mode 100644 index 000000000..db6dd008b --- /dev/null +++ b/app/javascript/controllers/bar_controller.js @@ -0,0 +1,79 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" + +export default class extends Controller { + static targets = [ "turboFrame", "searchInput", "askInput", "buttonsContainer" ] + static outlets = [ "dialog" ] + static values = { + searchUrl: String, + askUrl: String, + } + + dialogOutletConnected(outlet, element) { + outlet.close() + this.#clearTurboFrame() + } + + reset() { + this.dialogOutlet.close() + this.#clearTurboFrame() + + this.#showItem(this.buttonsContainerTarget) + this.#hideItem(this.askInputTarget) + this.#hideItem(this.searchInputTarget) + } + + showModalAndSubmit(event) { + event.preventDefault() + + const form = event.target.closest("form") + + this.showModal() + form.requestSubmit() + } + + showModal() { + this.dialogOutlet.open() + } + + search(event) { + this.#openInTurboFrame(this.searchUrlValue) + + this.#showItem(this.searchInputTarget) + this.#hideItem(this.askInputTarget) + this.#hideItem(this.buttonsContainerTarget) + } + + ask(event) { + this.#initializeConversation() + this.#openInTurboFrame(this.askUrlValue) + + this.#showItem(this.askInputTarget) + this.#hideItem(this.searchInputTarget) + this.#hideItem(this.buttonsContainerTarget) + } + + #clearTurboFrame() { + this.turboFrameTarget.removeAttribute("src") + this.turboFrameTarget.innerHtml = "" + } + + #openInTurboFrame(url) { + this.turboFrameTarget.src = url + } + + #initializeConversation() { + post(this.askUrlValue) + } + + #showItem(element) { + element.removeAttribute("hidden") + + const autofocusElement = element.querySelector("[autofocus]") + autofocusElement?.focus() + } + + #hideItem(element) { + element.setAttribute("hidden", "hidden") + } +} diff --git a/app/javascript/controllers/conversation/composer_controller.js b/app/javascript/controllers/conversation/composer_controller.js index 0716d7be4..368716846 100644 --- a/app/javascript/controllers/conversation/composer_controller.js +++ b/app/javascript/controllers/conversation/composer_controller.js @@ -21,7 +21,7 @@ export default class extends Controller { } } - submitEnd(even) { + submitEnd(event) { this.#submittingMessage = false if (event.detail.success) { diff --git a/app/javascript/controllers/conversation/messages_controller.js b/app/javascript/controllers/conversation/messages_controller.js index 05ea65979..4d484ee2d 100644 --- a/app/javascript/controllers/conversation/messages_controller.js +++ b/app/javascript/controllers/conversation/messages_controller.js @@ -34,7 +34,7 @@ export default class extends Controller { } scrollToBottom() { - this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight + this.element.scrollTop = this.element.scrollHeight } beforeStreamRender(event) { diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js deleted file mode 100644 index 2d37ee9e3..000000000 --- a/app/javascript/controllers/terminal_controller.js +++ /dev/null @@ -1,91 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { HttpStatus } from "helpers/http_helpers" -import { isMultiLineString } from "helpers/text_helpers"; -import { marked } from "marked" -import { nextFrame } from "helpers/timing_helpers"; - -export default class extends Controller { - static targets = [ "input", "form", "modalTurboFrame" ] - static classes = [ "error", "busy" ] - static outlets = [ "dialog" ] - - // Actions - - async focus() { - await nextFrame() - - this.inputTarget.focus() - this.inputTarget.selection.placeCursorAtTheEnd() - } - - executeCommand(event) { - if (!this.inputTarget.value.trim()) { - event.preventDefault() - } - } - - submitCommand({ target }) { - this.#submitCommand() - } - - handleCommandResponse(event) { - const response = event.detail.fetchResponse?.response - - if (event.detail.success) { - this.#handleSuccessResponse(response) - } else if (response) { - this.#handleErrorResponse(response) - } - } - - hideError() { - this.element.classList.remove(this.errorClass) - } - - commandSubmitted() { - this.element.classList.add(this.busyClass) - } - - #reset(inputValue = "") { - this.inputTarget.value = inputValue - - this.element.classList.remove(this.errorClass) - this.element.classList.remove(this.busyClass) - } - - #handleSuccessResponse(response) { - if (response.headers.get("Content-Type")?.includes("application/json")) { - response.json().then((responseJson) => { - this.#handleJsonResponse(responseJson) - }) - } - this.#reset() - } - - async #handleErrorResponse(response) { - this.element.classList.add(this.errorClass) - } - - #handleJsonResponse(responseJson) { - const { redirect_to, turbo_frame, url } = responseJson - - if (redirect_to) { - Turbo.visit(redirect_to) - } - - if (turbo_frame && url) { - this.#showTurboFrameModal(turbo_frame, url) - } - } - - #submitCommand() { - this.formTarget.requestSubmit() - } - - #showTurboFrameModal(name, url) { - this.inputTarget.blur() - this.modalTurboFrameTarget.id = name - this.modalTurboFrameTarget.src = url - this.dialogOutlet.open() - } -} diff --git a/app/models/command.rb b/app/models/command.rb deleted file mode 100644 index 740aa3a47..000000000 --- a/app/models/command.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Command - include Rails.application.routes.url_helpers - - def execute - raise NotImplementedError - end - - private - def redirect_to(...) - Command::Result::Redirection.new(...) - end -end diff --git a/app/models/command/ask.rb b/app/models/command/ask.rb deleted file mode 100644 index 243ba0237..000000000 --- a/app/models/command/ask.rb +++ /dev/null @@ -1,14 +0,0 @@ -class Command::Ask < Command - attr_reader :question - - def initialize(question) - @question = question - end - - def execute - conversation = Conversation.create_or_find_by(user: Current.user) - conversation.ask(question) if question.present? - - Command::Result::ShowModal.new(turbo_frame: "conversation", url: conversation_path) - end -end diff --git a/app/models/command/go_to_card.rb b/app/models/command/go_to_card.rb deleted file mode 100644 index bee418648..000000000 --- a/app/models/command/go_to_card.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Command::GoToCard < Command - attr_reader :card - - def initialize(card) - @card = card - end - - def execute - redirect_to card - end -end diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb deleted file mode 100644 index b953fcd32..000000000 --- a/app/models/command/parser.rb +++ /dev/null @@ -1,51 +0,0 @@ -class Command::Parser - attr_reader :user, :script_name - - def initialize(user: user, script_name:) - @user = user - @script_name = script_name - end - - def parse(string) - parse_command(string).tap do |command| - command.default_url_options[:script_name] = script_name - end - end - - private - def parse_command(string) - parse_rich_text_command as_plain_text_with_attachable_references(string) - end - - def as_plain_text_with_attachable_references(string) - ActionText::Content.new(string).render_attachments(&:to_gid).fragment.to_plain_text - end - - def parse_rich_text_command(string) - command_name, *command_arguments = string.strip.split(" ") - combined_arguments = command_arguments.join(" ") - - case command_name - when "/ask" - Command::Ask.new(combined_arguments) - else - parse_free_string(string) - end - end - - def parse_free_string(string) - if card = single_card_from(string) - Command::GoToCard.new(card) - else - parse_with_fallback(string) - end - end - - def single_card_from(string) - user.accessible_cards.find_by_id(string) - end - - def parse_with_fallback(string) - Command::Search.new(string) - end -end diff --git a/app/models/command/result/redirection.rb b/app/models/command/result/redirection.rb deleted file mode 100644 index 3796558e5..000000000 --- a/app/models/command/result/redirection.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Command::Result::Redirection - attr_reader :url - - def initialize(url) - @url = url - end - - def as_json - { redirect_to: url } - end -end diff --git a/app/models/command/result/show_modal.rb b/app/models/command/result/show_modal.rb deleted file mode 100644 index 19922ab5f..000000000 --- a/app/models/command/result/show_modal.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Command::Result::ShowModal - attr_reader :url, :turbo_frame - - def initialize(url:, turbo_frame:) - @url = url - @turbo_frame = turbo_frame - end - - def as_json - { turbo_frame: turbo_frame, url: url } - end -end diff --git a/app/models/command/search.rb b/app/models/command/search.rb deleted file mode 100644 index 09befa32a..000000000 --- a/app/models/command/search.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Command::Search < Command - attr_reader :terms - - def initialize(terms) - @terms = terms - end - - - def execute - redirect_to search_path(q: terms) - end -end diff --git a/app/models/conversation/broadcastable.rb b/app/models/conversation/broadcastable.rb index b9cd0b147..8eedc14c7 100644 --- a/app/models/conversation/broadcastable.rb +++ b/app/models/conversation/broadcastable.rb @@ -3,7 +3,7 @@ module Conversation::Broadcastable def broadcast_state_change broadcast_replace_to user, :conversation, - target: [ self, :thinking_indicator ], - partial: "conversations/show/thinking_indicator" + target: "conversation-thinking-indicator", + partial: "conversations/composer/thinking_indicator" end end diff --git a/app/models/user.rb b/app/models/user.rb index ee00bb158..bd8cf990d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ class User < ApplicationRecord include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher, - SignalUser, Staff, Transferable + SignalUser, Staff, Transferable, Conversational include Timelined # Depends on Accessor has_one_attached :avatar @@ -20,8 +20,6 @@ class User < ApplicationRecord has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all has_many :period_activity_summaries, dependent: :destroy - has_one :conversation, dependent: :destroy - normalizes :email_address, with: ->(value) { value.strip.downcase } def deactivate diff --git a/app/models/user/conversational.rb b/app/models/user/conversational.rb new file mode 100644 index 000000000..3f64462f2 --- /dev/null +++ b/app/models/user/conversational.rb @@ -0,0 +1,11 @@ +module User::Conversational + extend ActiveSupport::Concern + + included do + has_one :conversation, dependent: :destroy + end + + def start_or_continue_conversation + conversation || create_conversation! + end +end diff --git a/app/views/bar/_bar.html.erb b/app/views/bar/_bar.html.erb new file mode 100644 index 000000000..87c133be6 --- /dev/null +++ b/app/views/bar/_bar.html.erb @@ -0,0 +1,48 @@ +<%= tag.div \ + class: "bar full-width", + data: { + controller: "bar", + bar_dialog_outlet: "#bar-modal", + bar_search_url_value: search_path, + bar_ask_url_value: conversation_path + } do %> +
+ <%= tag.button \ + class: "btn btn--plain", + data: { + controller: "hotkey", + action: " + bar#search + keydown.meta+k@document->hotkey#click keydown.ctrl+k@document->hotkey#click" } do %> + Search [<%= hotkey_label(["ctrl", "K"]) %>] + <% end %> + or + <%= tag.button \ + class: "btn btn--plain", + data: { + controller: "hotkey", + action: " + bar#ask + keydown.meta+a@document->hotkey#click keydown.ctrl+a@document->hotkey#click" } do %> + Ask [<%= hotkey_label(["ctrl", "A"]) %>] + <% end %> +
+ + + + + + <%= tag.dialog \ + id: "bar-modal", + class: "bar__modal", + data: { + controller: "dialog", + dialog_target: "dialog", + action: "keydown.esc->bar#reset:stop" } do %> + <%= turbo_frame_tag "bar-content", data: { bar_target: "turboFrame" } %> + <% end %> +<% end %> diff --git a/app/views/commands/_form.html.erb b/app/views/commands/_form.html.erb deleted file mode 100644 index cc5bf65a7..000000000 --- a/app/views/commands/_form.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%= form_tag commands_path, - id: "commands_form", - class: [ "flex align-center gap-half" ], - data: { - controller: "form", - terminal_target: "form", - action: " - keydown.enter->terminal#submitCommand - turbo:submit-start->terminal#commandSubmitted - turbo:submit-end->terminal#focus - keydown.meta+k@document->terminal#focus:prevent - keydown.ctrl+k@document->terminal#focus:prevent - turbo:submit-end->terminal#handleCommandResponse - " - } do %> - <%= rich_textarea_tag "command", nil, - toolbar: false, - class: "terminal__input input hide-focus-ring fill-transparent unpad", - "single-line": true, - id: "command", - attachments: false, - data: { - terminal_target: "input" - }, - placeholder: platform.desktop? ? "Press #{ hotkey_label(["ctrl", "K"]) } to search or type /commands…" : "Search or type /commands…", - spellcheck: "false" do %> - <%= global_mentions_prompt %> - <%= tags_prompt %> - <% end %> -<% end %> diff --git a/app/views/commands/_help.html.erb b/app/views/commands/_help.html.erb deleted file mode 100644 index 0f0135663..000000000 --- a/app/views/commands/_help.html.erb +++ /dev/null @@ -1,37 +0,0 @@ -
-

Fizzy do > Help

-
-

Search

-
Type keywords to search cards and comments
-
"CSS bug"
- -

Navigate

-
Type a card ID to go to it
-
"137"
-
Type a name to see someone’s profile
-
"@jason"
- -

Filter

-
Type card IDs to isolate them
-
"131, 137, 39"
-
Type a tag to see just tagged cards
-
"#design"
- -

Commands

-
Type / to issue commands
-
"/move to Doing"
- -
Type @ to match people
-
"/assign to @jason"
- -
Type # to match tags
-
"/tag this #design"
- -

Tips

-
Combine for powerful actions
-
"/move to Doing and /assign to @jason"
- -
Or use natural language
-
"Show cards added by Jason this month"
-
-
diff --git a/app/views/commands/_terminal.html.erb b/app/views/commands/_terminal.html.erb deleted file mode 100644 index 4c7ad5fe5..000000000 --- a/app/views/commands/_terminal.html.erb +++ /dev/null @@ -1,27 +0,0 @@ -<%= tag.div \ - id: "command-terminal", - class: "terminal full-width flex flex-column justify-end", - data: { - controller: "terminal", - terminal_dialog_outlet: "#terminal-modal", - terminal_error_class: "terminal--error", - terminal_confirmation_class: "terminal--confirmation", - terminal_help_class: "terminal--showing-help", - terminal_output_class: "terminal--showing-output", - terminal_busy_class: "terminal--busy", - toggle_class_toggle_class: "terminal--open" - } do %> - <%= tag.dialog \ - id: "terminal-modal", - class: "terminal__modal panel shadow", - data: { - controller: "dialog", - dialog_target: "dialog", - dialog_modal_value: "true", - action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" } do %> - <%= turbo_frame_tag "terminal-turbo-frame", refresh: :morph, data: { terminal_target: "modalTurboFrame" }, target: "_top" %> - <% end %> - - <%= render "commands/form" %> -<% end %> - diff --git a/app/views/conversations/_composer.html.erb b/app/views/conversations/_composer.html.erb new file mode 100644 index 000000000..36ab71173 --- /dev/null +++ b/app/views/conversations/_composer.html.erb @@ -0,0 +1,23 @@ +<%= form_with model: Conversation::Message.new, local: true, + class: "conversation__composer flex align-center justify-center gap-half", + data: { + turbo_frame: "conversation", + action: "turbo:submit-end->conversation--composer#submitEnd", + controller: "conversation--composer", + conversation__composer_conversation__messages_outlet: "#conversation-messages" } do |form| %> + <%= render "conversations/composer/thinking_indicator", conversation: nil %> + + <%= form.hidden_field :client_message_id, data: { conversation__composer_target: "clientMessageIdInput" } %> + <%= form.label :content, "Ask Fizzy", class: "font-weight-black txt-nowrap" %> + <%= form.text_field :content, + autofocus: true, + autocomplete: "off", + placeholder: "Ask a question…", + class: "conversation__composer-input input", + data: { + action: " + keydown.esc->bar#reset + keydown.enter->conversation--composer#submit keydown.enter->bar#showModal + keydown.ctrl+enter->conversation--composer#submit keydown.ctrl+enter->bar#showModal", + conversation__composer_target: "textInput" } %> +<% end %> diff --git a/app/views/conversations/composer/_thinking_indicator.html.erb b/app/views/conversations/composer/_thinking_indicator.html.erb new file mode 100644 index 000000000..07b34d79c --- /dev/null +++ b/app/views/conversations/composer/_thinking_indicator.html.erb @@ -0,0 +1,4 @@ +<%= tag.div id: "conversation-thinking-indicator", + class: "conversation__thinking-indicator conversation__thinking-indicator--#{conversation&.state || :unknown}" do %> +
+<% end %> diff --git a/app/views/conversations/messages/index.html.erb b/app/views/conversations/messages/index.html.erb index df9a2488f..6d889b142 100644 --- a/app/views/conversations/messages/index.html.erb +++ b/app/views/conversations/messages/index.html.erb @@ -1,6 +1,6 @@ <%= turbo_frame_tag dom_id(@conversation, :messages_container) do %> <%= tag.div \ - id: dom_id(@conversation, :messages), + id: "conversation-messages", class: "conversation__messages", data: { controller: "conversation--messages", diff --git a/app/views/conversations/show.html.erb b/app/views/conversations/show.html.erb index ba045611d..16f8d7de1 100644 --- a/app/views/conversations/show.html.erb +++ b/app/views/conversations/show.html.erb @@ -1,18 +1,15 @@ -<%= turbo_frame_tag "conversation", target: "_top" do %> +<%= turbo_frame_tag "bar-content" do %> <%= turbo_stream_from Current.user, :conversation %> +
- Fizzy /Ask -
- -
+
+ <%= turbo_frame_tag dom_id(@conversation, :messages_container), src: conversation_messages_path, target: "_top" %> - <%= render partial: "conversations/show/thinking_indicator", locals: { conversation: @conversation } %> - <%= render partial: "conversations/show/composer", locals: { conversation: @conversation } %>
<% end %> diff --git a/app/views/conversations/show/_composer.html.erb b/app/views/conversations/show/_composer.html.erb deleted file mode 100644 index 517ea2455..000000000 --- a/app/views/conversations/show/_composer.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= form_with model: Conversation::Message.new, local: true, - id: dom_id(conversation, :composer), - class: "conversation__composer", - data: { - action: "turbo:submit-end->conversation--composer#submitEnd", - controller: "conversation--composer", - conversation__composer_conversation__messages_outlet: "##{dom_id(conversation, :messages)}" } do |form| %> - - <%= form.hidden_field :client_message_id, data: { conversation__composer_target: "clientMessageIdInput" } %> - -
- <%= form.text_area :content, autofocus: "on", placeholder: "Ask something…", rows: 1, - class: "input input--textarea", data: { action: "keydown.enter->conversation--composer#submit keydown.ctrl+enter->conversation--composer#submit", conversation__composer_target: "textInput" } %> -
- -
- <%= form.button type: "submit", class: "btn btn--link txt-small", - data: { conversation__composer_target: "submitButton", action: "click->conversation--composer#submit"} do %> - <%= icon_tag "arrow-up" %> - Send message - <% end %> -
-<% end %> diff --git a/app/views/conversations/show/_thinking_indicator.html.erb b/app/views/conversations/show/_thinking_indicator.html.erb deleted file mode 100644 index f08f48f9b..000000000 --- a/app/views/conversations/show/_thinking_indicator.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= tag.div id: dom_id(conversation, :thinking_indicator), - class: "conversation__thinking-indicator conversation__thinking-indicator--#{conversation.state}" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1d72a8bb8..ff43e5d5b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,8 +19,8 @@ <% if Current.user && !@hide_footer_frames %> <% end %> diff --git a/app/views/searches/_form.html.erb b/app/views/searches/_form.html.erb index 9b345f5a6..a0f8b3448 100644 --- a/app/views/searches/_form.html.erb +++ b/app/views/searches/_form.html.erb @@ -1,4 +1,11 @@ -<%= form_with url: search_path, method: :get, class: "search__form" do |form| %> - <%= text_field_tag :q, query_terms, class: "search__form-input input", type: "search", placeholder: "What are you looking for?" %> - <%= icon_tag "search", class: "search__form-icon" %> +<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { turbo_frame: "bar-content" } do |form| %> + <%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %> + <%= text_field_tag :q, query_terms, + class: "search__form-input input", + type: "search", + placeholder: "Find something…", + autocomplete: "off", + autofocus: true, + data: { + action: "keydown.enter->bar#showModalAndSubmit keydown.esc->bar#reset" } %> <% end %> diff --git a/app/views/searches/_result.html.erb b/app/views/searches/_result.html.erb index 810135262..6fa042d34 100644 --- a/app/views/searches/_result.html.erb +++ b/app/views/searches/_result.html.erb @@ -1,18 +1,8 @@
  • - <%= link_to result.source, class: "card card--search" do %> -
    - -
    - -
    -
    -

    - <%= result.card_title&.html_safe %> + <%= link_to result.source, class: "search__result", data: { turbo_frame: "_top" } do %> +
    +

    + # <%= result.card_id %> <%= result.card_title&.html_safe %>

    <% if result.card_description.present? %> @@ -30,6 +20,6 @@ <% end %> <% end %>
    -

    +
    <%= result.collection_name %>
    <% end %>
  • diff --git a/app/views/searches/_result_mockup.html.erb b/app/views/searches/_result_mockup.html.erb deleted file mode 100644 index de5efaf38..000000000 --- a/app/views/searches/_result_mockup.html.erb +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/app/views/searches/_search_query.html.erb b/app/views/searches/_search_query.html.erb deleted file mode 100644 index f092ba9c7..000000000 --- a/app/views/searches/_search_query.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
  • - <%= link_to search_query.terms, search_path(q: search_query.terms), class: "btn txt-small" %> -
  • diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb index e734a4391..b3be862d8 100644 --- a/app/views/searches/show.html.erb +++ b/app/views/searches/show.html.erb @@ -1,38 +1,22 @@ -<% @page_title = "Search" %> - -<% content_for :header do %> - -<% end %> - -<%= auto_submit_form_with url: searches_queries_path(q: @query_terms), method: :post %> - - + +<% end %> diff --git a/config/routes.rb b/config/routes.rb index c447b1203..6d55b04e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,7 +111,7 @@ Rails.application.routes.draw do resources :commands - resource :conversation, only: %i[ show ] do + resource :conversation, only: %i[ show create ] do scope module: :conversations do resources :messages, only: %i[ index create ] end diff --git a/test/controllers/commands_controller_test.rb b/test/controllers/commands_controller_test.rb deleted file mode 100644 index f5c691594..000000000 --- a/test/controllers/commands_controller_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "test_helper" - -class CommandsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in_as :kevin - end - - test "command that results in a redirect" do - post commands_path, params: { command: "#{cards(:logo).id}" } - - assert_redirected_to cards(:logo) - end - - test "command that triggers a redirect back" do - post commands_path, params: { command: "design" } - - assert_redirected_to search_path(q: "design") - end -end diff --git a/test/models/command/go_to_card_test.rb b/test/models/command/go_to_card_test.rb deleted file mode 100644 index d4609d5f1..000000000 --- a/test/models/command/go_to_card_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "test_helper" - -class Command::GoToCardTest < ActionDispatch::IntegrationTest - include CommandTestHelper - - setup do - @card = cards(:logo) - end - - test "redirect to the card perma" do - result = execute_command "#{@card.id}" - - assert_equal @card, result.url - end - - test "result in a regular search if the card does not exist" do - command = parse_command "123" - - result = command.execute - assert_equal search_path(q: "123"), result.url - end -end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index d59871312..d10fc73bc 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -49,4 +49,15 @@ class UserTest < ActiveSupport::TestCase assert_not_includes User.active, system_user end + + test "start or continue conversation" do + user = users(:jz) + + assert_nil user.conversation + + conversation = user.start_or_continue_conversation + + assert user.conversation + assert_equal user.conversation, conversation + end end