From 57e7a92cd17d75fc3a1607ce672cdcc3fe74b222 Mon Sep 17 00:00:00 2001 From: Zoe <62722391+juls0730@users.noreply.github.com> Date: Mon, 11 May 2026 17:44:00 -0500 Subject: [PATCH] fix: UI polish and provider fixes - Add animate-pulse CSS animation for loading states - Add Ctrl+I/Ctrl+B markdown shortcuts in chat input - Improve chat input resize with mirror element - Add reasoning duration display and shimmer effect - Add confirmation dialog for deleting all models - Simplify RowVirtualizerDynamic resize handling - Fix DialogType imports (type -> value) - Fix ollama cloud model name resolution - Make vllm auth header optional - Various icon and styling fixes --- app/assets/css/base.css | 39 +++++++++ app/components/ChatInput.vue | 79 +++++++++++++++++-- app/components/Message/Agent/Reasoning.vue | 26 +++++- app/components/ModelSelector.vue | 2 +- app/components/RowVirtualizerDynamic.vue | 19 +---- app/components/SearchSelector.vue | 2 +- app/components/Settings/AIServiceProvider.vue | 34 ++++++-- app/components/Sidenav/Header.vue | 2 +- app/components/Sidenav/HeaderAgent.vue | 1 + app/components/Sidenav/NavAgent.vue | 2 +- app/components/ThemeSwitcher.vue | 4 +- server/api/agent/index.post.ts | 1 + server/utils/providers/ollama.ts | 3 +- server/utils/providers/vllm.ts | 2 +- 14 files changed, 177 insertions(+), 39 deletions(-) diff --git a/app/assets/css/base.css b/app/assets/css/base.css index 7b994a5..926a4b8 100644 --- a/app/assets/css/base.css +++ b/app/assets/css/base.css @@ -224,6 +224,45 @@ button.accent:hover { animation: blink 1s step-end infinite; } +.animate-pulse { + background: linear-gradient(90deg, + currentColor 0%, + currentColor 35%, + rgba(255, 255, 255, 0.65) 50%, + currentColor 65%, + currentColor 100%); + background-size: 300% 100%; + background-repeat: no-repeat; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: + pulse-shimmer 2s linear infinite, + pulse-opacity 2s ease-in-out infinite; +} + +@keyframes pulse-shimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0% 0; + } +} + +@keyframes pulse-opacity { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } +} + @keyframes blink { 0%, diff --git a/app/components/ChatInput.vue b/app/components/ChatInput.vue index f961f49..cad4ce2 100644 --- a/app/components/ChatInput.vue +++ b/app/components/ChatInput.vue @@ -32,6 +32,7 @@ watch(files, (newFiles) => { const emit = defineEmits<{ submit: [value: BaseMessage, model: ModelWithProvider | null]; cancel: []; + resize: []; }>(); const props = defineProps<{ @@ -147,11 +148,50 @@ const handleSubmit = () => { files.value = []; textAreaValue.value = ''; } - // Reset height after sending - if (inputRef.value) inputRef.value.style.height = 'auto'; +}; + +const toggleMarkdown = (marker: '*' | '**') => { + const textarea = inputRef.value; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + if (start === end) return; + + const text = textAreaValue.value; + const len = marker.length; + + let countBefore = 0; + for (let i = start - 1; i >= 0 && text[i] === '*'; i--) countBefore++; + let countAfter = 0; + for (let i = end; i < text.length && text[i] === '*'; i++) countAfter++; + + const shouldRemove = len === 1 + ? countBefore >= 1 && countAfter >= 1 && countBefore % 2 === 1 && countAfter % 2 === 1 + : countBefore >= 2 && countAfter >= 2; + + if (shouldRemove) { + textAreaValue.value = text.slice(0, start - len) + text.slice(start, end) + text.slice(end + len); + nextTick(() => textarea.setSelectionRange(start - len, end - len)); + } else { + textAreaValue.value = text.slice(0, start) + marker + text.slice(start, end) + marker + text.slice(end); + nextTick(() => textarea.setSelectionRange(start + len, end + len)); + } }; const handleKeyDown = async (event: KeyboardEvent) => { + if (event.ctrlKey && event.key.toLowerCase() === 'i') { + event.preventDefault(); + event.stopPropagation(); + toggleMarkdown('*'); + return; + } + if (event.ctrlKey && event.key.toLowerCase() === 'b') { + event.preventDefault(); + event.stopPropagation(); + toggleMarkdown('**'); + return; + } if (event.key === 'Enter') { if (event.shiftKey) return; if (event.ctrlKey || event.metaKey) { @@ -195,16 +235,43 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => { } }; +const mirrorRef = ref(null); + const resizeTextArea = () => { const textarea = inputRef.value; - if (!textarea) return; + const mirror = mirrorRef.value; + if (!textarea || !mirror) return; - inputHeight.value = 'auto'; + emit('resize'); nextTick().then(() => { + mirror.textContent = (textarea.value || '\u200b') + '\n'; + + const textareaStyles = getComputedStyle(textarea); + mirror.style.width = textareaStyles.width; + mirror.style.fontFamily = textareaStyles.fontFamily; + mirror.style.fontSize = textareaStyles.fontSize; + mirror.style.lineHeight = textareaStyles.lineHeight; + mirror.style.letterSpacing = textareaStyles.letterSpacing; + mirror.style.wordSpacing = textareaStyles.wordSpacing; + mirror.style.textAlign = textareaStyles.textAlign; + mirror.style.paddingTop = textareaStyles.paddingTop; + mirror.style.paddingBottom = textareaStyles.paddingBottom; + mirror.style.paddingLeft = textareaStyles.paddingLeft; + mirror.style.paddingRight = textareaStyles.paddingRight; + mirror.style.borderTopWidth = textareaStyles.borderTopWidth; + mirror.style.borderBottomWidth = textareaStyles.borderBottomWidth; + mirror.style.borderLeftWidth = textareaStyles.borderLeftWidth; + mirror.style.borderRightWidth = textareaStyles.borderRightWidth; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.wordWrap = 'break-word'; + mirror.style.overflow = 'hidden'; + const lineHeight = 24; + const minLines = 2; const maxLines = 10; + const minHeight = minLines * lineHeight; const maxHeight = maxLines * lineHeight; - const height = Math.min(textarea.scrollHeight, maxHeight); + const height = Math.min(Math.max(mirror.scrollHeight, minHeight), maxHeight); inputHeight.value = `${height}px`; }); }; @@ -249,6 +316,8 @@ onUnmounted(() => {