Merge pull request #932 from basecamp/fizzy-bar-v1

Fizzy bar V1
This commit is contained in:
Stanko Krtalić
2025-08-25 07:16:42 +02:00
committed by GitHub
43 changed files with 392 additions and 900 deletions
+12
View File
@@ -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; }
+52
View File
@@ -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;
}
}
}
-32
View File
@@ -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);
}
}
}
+39 -39
View File
@@ -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;
}
}
+50 -65
View File
@@ -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,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m14.3 12.179a.25.25 0 0 1 0-.354l9.263-9.262a1.5 1.5 0 0 0 -2.124-2.121l-9.262 9.258a.25.25 0 0 1 -.354 0l-9.262-9.258a1.5 1.5 0 0 0 -2.122 2.121l9.261 9.262a.25.25 0 0 1 0 .354l-9.261 9.263a1.5 1.5 0 0 0 2.122 2.121l9.262-9.263a.25.25 0 0 1 .354 0l9.262 9.263a1.5 1.5 0 0 0 2.122-2.121z"/></svg>');
background-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m14.3 12.179a.25.25 0 0 1 0-.354l9.263-9.262a1.5 1.5 0 0 0 -2.124-2.121l-9.262 9.258a.25.25 0 0 1 -.354 0l-9.262-9.258a1.5 1.5 0 0 0 -2.122 2.121l9.261 9.262a.25.25 0 0 1 0 .354l-9.261 9.263a1.5 1.5 0 0 0 2.122 2.121l9.262-9.263a.25.25 0 0 1 .354 0l9.262 9.263a1.5 1.5 0 0 0 2.122-2.121z" fill="%23000"/></svg>');
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,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m14.3 12.179a.25.25 0 0 1 0-.354l9.263-9.262a1.5 1.5 0 0 0 -2.124-2.121l-9.262 9.258a.25.25 0 0 1 -.354 0l-9.262-9.258a1.5 1.5 0 0 0 -2.122 2.121l9.261 9.262a.25.25 0 0 1 0 .354l-9.261 9.263a1.5 1.5 0 0 0 2.122 2.121l9.262-9.263a.25.25 0 0 1 .354 0l9.262 9.263a1.5 1.5 0 0 0 2.122-2.121z" fill="%23fff"/></svg>');
}
}
}
/* 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;
}
}
-260
View File
@@ -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);
}
}
}
+9
View File
@@ -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] {
-34
View File
@@ -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
@@ -1,4 +1,8 @@
class ConversationsController < ApplicationController
def create
Current.user.start_or_continue_conversation
end
def show
@conversation = Current.user.conversation
end
@@ -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")
}
}
@@ -21,7 +21,7 @@ export default class extends Controller {
}
}
submitEnd(even) {
submitEnd(event) {
this.#submittingMessage = false
if (event.detail.success) {
@@ -34,7 +34,7 @@ export default class extends Controller {
}
scrollToBottom() {
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
this.element.scrollTop = this.element.scrollHeight
}
beforeStreamRender(event) {
@@ -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()
}
}
-12
View File
@@ -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
-14
View File
@@ -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
-11
View File
@@ -1,11 +0,0 @@
class Command::GoToCard < Command
attr_reader :card
def initialize(card)
@card = card
end
def execute
redirect_to card
end
end
-51
View File
@@ -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
-11
View File
@@ -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
-12
View File
@@ -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
-12
View File
@@ -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
+2 -2
View File
@@ -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
+1 -3
View File
@@ -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
+11
View File
@@ -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
+48
View File
@@ -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 %>
<div class="flex justify-center gap bar__placeholder" data-bar-target="buttonsContainer">
<%= 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 %>
<span>Search [<%= hotkey_label(["ctrl", "K"]) %>]</span>
<% end %>
<span>or</span>
<%= 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 %>
<span>Ask [<%= hotkey_label(["ctrl", "A"]) %>]</span>
<% end %>
</div>
<div class="bar__input" data-bar-target="searchInput" hidden>
<%= render "searches/form", query_terms: "" %>
</div>
<div class="bar__input" data-bar-target="askInput" hidden>
<%= render "conversations/composer" %>
</div>
<%= 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 %>
-30
View File
@@ -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 %>
-37
View File
@@ -1,37 +0,0 @@
<section class="terminal__help shadow" aria-labelledby="help-menu-title">
<h2 id="help-menu-title"><strong>Fizzy do > Help</strong></h2>
<dl>
<h3>Search</h3>
<dt>Type keywords to search cards and comments</dt>
<dd>"CSS bug"</dd>
<h3>Navigate</h3>
<dt>Type a card ID to go to it</dt>
<dd>"137"</dd>
<dt>Type a name to see someones profile</dt>
<dd>"@jason"</dd>
<h3>Filter</h3>
<dt>Type card IDs to isolate them</dt>
<dd>"131, 137, 39"</dd>
<dt>Type a tag to see just tagged cards</dt>
<dd>"#design"</dd>
<h3>Commands</h3>
<dt>Type / to issue commands</dt>
<dd>"/move to Doing"</dd>
<dt>Type @ to match people</dt>
<dd>"/assign to @jason"</dd>
<dt>Type # to match tags</dt>
<dd>"/tag this #design"</dd>
<h3>Tips</h3>
<dt>Combine for powerful actions</dt>
<dd>"/move to Doing and /assign to @jason"</dd>
<dt>Or use natural language</dt>
<dd>"Show cards added by Jason this month"</dd>
</dl>
</section>
-27
View File
@@ -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 %>
@@ -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 %>
@@ -0,0 +1,4 @@
<%= tag.div id: "conversation-thinking-indicator",
class: "conversation__thinking-indicator conversation__thinking-indicator--#{conversation&.state || :unknown}" do %>
<div class="spinner"></div>
<% end %>
@@ -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",
+7 -10
View File
@@ -1,18 +1,15 @@
<%= turbo_frame_tag "conversation", target: "_top" do %>
<%= turbo_frame_tag "bar-content" do %>
<%= turbo_stream_from Current.user, :conversation %>
<div class="conversation">
<div class="conversation__header">
<span class="txt-medium font-weight-black margin-inline-start-half">Fizzy /Ask</span>
<form method="dialog">
<button class="btn txt-x-small">
<%= icon_tag "remove-med" %>
<span class="for-screen-reader">Close</span>
</button>
</form>
<button class="btn txt-x-small" data-action="bar#reset">
<%= icon_tag "remove-med" %>
<span class="for-screen-reader">Close</span>
</button>
</div>
<%= 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 } %>
</div>
<% end %>
@@ -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" } %>
<div class="conversation__input">
<%= 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" } %>
</div>
<div class="conversation__submit">
<%= 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" %>
<span class="for-screen-reader">Send message</span>
<% end %>
</div>
<% end %>
@@ -1,2 +0,0 @@
<%= tag.div id: dom_id(conversation, :thinking_indicator),
class: "conversation__thinking-indicator conversation__thinking-indicator--#{conversation.state}" %>
+1 -1
View File
@@ -19,8 +19,8 @@
<% if Current.user && !@hide_footer_frames %>
<div id="footer_frames" data-turbo-permanent="true">
<%= render "bar/bar" %>
<%= render "my/pins/tray" %>
<%= render "commands/terminal" %>
<%= render "notifications/tray" %>
</div>
<% end %>
+10 -3
View File
@@ -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 %>
+5 -15
View File
@@ -1,18 +1,8 @@
<li>
<%= link_to result.source, class: "card card--search" do %>
<header class="card__header">
<label class="card__collection flex align-center">
<span class="card__id"><%= result.card_id %></span>
<span class="card__collection-name">
<span class="overflow-ellipsis"><%= result.collection_name %></span>
</span>
</label>
</header>
<div class="card__body justify-space-between">
<div class="card__content">
<h3 class="card__title">
<%= result.card_title&.html_safe %>
<%= link_to result.source, class: "search__result", data: { turbo_frame: "_top" } do %>
<div>
<h3 class="search__title txt--medium margin-none">
# <%= result.card_id %> <%= result.card_title&.html_safe %>
</h3>
<% if result.card_description.present? %>
@@ -30,6 +20,6 @@
<% end %>
<% end %>
</div>
</div>
<div class="overflow-ellipsis translucent txt-ink"><%= result.collection_name %></div>
<% end %>
</li>
@@ -1,19 +0,0 @@
<div class="card card--search">
<header class="card__header">
<label class="card__collection flex align-center">
<span class="card__id">864</span>
<span class="card__collection-name">
<span class="overflow-ellipsis"><%= collection %></span>
</span>
</label>
</header>
<div class="card__body justify-space-between">
<div class="card__content">
<h3 class="card__title">
<%= title.html_safe %>
</h3>
<%= yield %>
</div>
</div>
</div>
@@ -1,3 +0,0 @@
<li>
<%= link_to search_query.terms, search_path(q: search_query.terms), class: "btn txt-small" %>
</li>
+20 -36
View File
@@ -1,38 +1,22 @@
<% @page_title = "Search" %>
<% content_for :header do %>
<nav class="header">
<div class="header__actions header__actions--start">
<%= link_to_back fallback_path: root_path %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<div class="header__actions header__actions--end"></div>
</nav>
<% end %>
<%= auto_submit_form_with url: searches_queries_path(q: @query_terms), method: :post %>
<section class="search">
<%= render "searches/form", query_terms: @query_terms %>
<div class="search__results">
<% if @query_terms.present? && @search_results.none? %>
<div class="search__empty">
No search results
</div>
<% end %>
<ul class="search__list">
<%= render partial: "searches/result", collection: @search_results %>
</ul>
<%= turbo_frame_tag "bar-content" do %>
<div class="search__header">
<button class="btn txt-x-small" data-action="bar#reset">
<%= icon_tag "remove-med" %>
<span class="for-screen-reader">Close</span>
</button>
</div>
<aside class="search__history">
<h3>Your recent searches…</h3>
<ul class="search__list">
<%= render partial: "searches/search_query", collection: @recent_search_queries.first(10) %>
</ul>
</aside>
</section>
<section class="search">
<div class="search__results">
<% if @search_results.none? %>
<div class="search__empty">
Not matches
</div>
<% end %>
<ul class="search__list">
<%= render partial: "searches/result", collection: @search_results %>
</ul>
</div>
</section>
<% end %>
+1 -1
View File
@@ -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
@@ -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
-22
View File
@@ -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
+11
View File
@@ -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