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
This commit is contained in:
Zoe
2026-05-11 17:44:00 -05:00
parent 6ee5b2cbbe
commit 57e7a92cd1
14 changed files with 177 additions and 39 deletions
+39
View File
@@ -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%,
+74 -5
View File
@@ -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<HTMLDivElement | null>(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(() => {
<template>
<div :class="['w-full flex max-h-full', $attrs.class]">
<div ref="mirrorRef" aria-hidden="true"
class="absolute top-0 left-0 pointer-events-none invisible overflow-hidden h-0"></div>
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
<div v-if="files.length > 0" class="flex-1 flex gap-2 pt-2 px-2 pb-1 overflow-x-auto flex-wrap">
+23 -3
View File
@@ -53,6 +53,20 @@ const toggleReasoning = async () => {
scrollToBottom('instant');
handleScroll();
};
const formatDuration = (ms: number) => {
const seconds = ms / 1000;
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${(seconds % 60).toFixed(1)}s`;
} else if (minutes > 0) {
return `${minutes}m ${(seconds % 60).toFixed(1)}s`;
} else {
return `${seconds.toFixed(1)}s`;
}
}
</script>
<template>
@@ -63,10 +77,16 @@ const toggleReasoning = async () => {
]">
<span class="flex items-center gap-1">
<span
class="w-[24px] h-[24px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span class="i-mynaui-atom text-2.5 text-[var(--reasoning-accent)]"></span>
class="w-6 h-6 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
<span class="i-mynaui-atom text-3 text-[var(--reasoning-accent)]"></span>
</span>
<span v-if="!part.finished" class="animate-pulse">
Deep Thinking
</span>
<span v-else>
Deeply Thought (in {{ formatDuration(new Date(part.lastUpdatedAt).getTime() - new
Date(part.createdAt).getTime()) }})
</span>
Deep Thinking
</span>
<span class="i-mynaui-chevron-down w-4 h-4 text-[var(--text-secondary)]"
:class="reasoningOpen ? '' : '-rotate-90'"></span>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog';
import { DialogType } from '~/composables/useDialog';
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
+2 -17
View File
@@ -33,29 +33,15 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
let resizeRafId: number | undefined = undefined;
const resizeObserver = new ResizeObserver(() => {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
resizeRafId = undefined;
rowVirtualizer.value.measure();
updateScrollMargin();
});
});
onMounted(() => {
resizeObserver.observe(containerRef.value!);
window.addEventListener('resize', updateScrollMargin);
updateScrollMargin();
});
onUnmounted(() => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateScrollMargin);
});
// Keep the measure function simple
const measureElement = (el: any) => {
if (el) {
rowVirtualizer.value.measureElement(el);
@@ -75,7 +61,6 @@ watch(() => props.items, () => {
top: 0,
left: 0,
width: '100%',
minHeight: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}">
<slot :item="props.items[virtualRow.index]" :index="virtualRow.index" />
+1 -1
View File
@@ -106,7 +106,7 @@ const openSettings = () => {
</span>
<button @click="adjustMaxResults(1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-plus"></span>
<span class="text-sm i-mynaui-plus-solid"></span>
</button>
</div>
</div>
+28 -6
View File
@@ -422,11 +422,33 @@ defineEmits(['navigate']);
<h4 class="whitespace-nowrap m-0 flex gap-x-2 items-start">
Model List
<span class="text-sm text-[var(--text-secondary)] font-normal text-xs flex items-center gap-1">
{{ provider?.models.length }} models available <button
class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200"
@click="deleteModels">
<span class="i-mynaui-x-solid"></span>
</button>
{{ provider?.models.length }} models available
<Dropdown placement="bottom">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="p-0.5 @hover:bg-[var(--color-hover)] rounded transition-colors duration-200">
<span class="i-mynaui-x-solid"></span>
</button>
</template>
<template #dropdown="{ close }">
<div class="px-3 py-2.5 w-64 flex flex-col gap-3">
<p class="text-sm text-[var(--text-primary)] m-0 leading-snug text-center">
Are you sure you want to delete all models? This will remove
<strong>all</strong> fetched models for this provider
</p>
<div class="flex gap-2 justify-end">
<button @click="close()"
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-md transition-colors duration-200 border border-[var(--color-border)] @hover:border-transparent">
Cancel
</button>
<button @click="deleteModels(); close()"
class="px-3 py-1.5 text-sm bg-red-500 text-white rounded-md @hover:bg-red-600 transition-colors duration-200">
Delete
</button>
</div>
</div>
</template>
</Dropdown>
</span>
</h4>
@@ -449,7 +471,7 @@ defineEmits(['navigate']);
<div class="flex">
<button @click="openAddPanel"
class="whitespace-nowrap flex bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] text-sm rounded-l-md items-center px-2 py-0.5 gap-2 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-plus text-5"></span>
<span class="i-mynaui-plus-solid text-5"></span>
</button>
<div class="relative">
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { DialogType } from '~/composables/useDialog';
import { DialogType } from '~/composables/useDialog';
const { user, signOut } = useAuth();
+1
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { navigateTo } from '#app';
const route = useRoute();
const { agents, getAgent } = await useAgents();
+1 -1
View File
@@ -182,7 +182,7 @@ onMounted(() => {
<div data-action="toggle-dropdown"
class="text-[var(--text-secondary)] shrink-0 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]"
:class="route.params.topicId === topic.id ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
:class="(route.params.topicId === topic.id || (dropdownOpen && activeMenuTopicId === topic.id)) ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
</div>
</a>
+2 -2
View File
@@ -30,7 +30,7 @@ const currentOption = computed(
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors active:text-[var(--text-primary)]"
:class="{
'h-9 w-9 md:h-7 md:w-7 text-5 md:text-6': size === 'small',
'h-9 w-9 md:h-7 md:w-7 md:text-5 text-6': size === 'small',
'h-9 w-9 text-6': size === 'medium',
'h-11 w-11 text-7': size === 'large',
}">
@@ -46,7 +46,7 @@ const currentOption = computed(
}">
<span class="text-4.5" :class="option.icon"></span>
<span class="text-sm whitespace-nowrap text-ellipsis max-w-full overflow-hidden">{{ option.label
}}</span>
}}</span>
</button>
</template>
</Dropdown>
+1
View File
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
});
}
// @ts-expect-error - shut up
await db.insert(agents).values(result.data).onConflictDoNothing();
userEvents.emit(userId, 'agents', {
op: 'create',
+2 -1
View File
@@ -103,12 +103,13 @@ export default {
normalizedId = normalizedId.replace(/:cloud$/, '');
normalizedId = normalizedId.replace(/-cloud$/, '');
normalizedId = normalizedId.replace(/:latest$/, '');
console.log(normalizedId);
const modelData = getModelData(normalizedId, 'ollama-cloud', modelsDevData);
models.set(model.name, {
...modelData,
name: modelData.name || model.name,
id: model.name,
name: model.name,
attributes: {
...modelData.attributes,
inputModalities: mergeSets(
+1 -1
View File
@@ -22,7 +22,7 @@ export default {
async fetchModels({ baseURL, apiKey }) {
const res = await fetch(`${baseURL}/models`, {
method: 'GET',
headers: { Authorization: `Bearer ${apiKey}` },
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
const data = await res.json();