diff --git a/TODO.md b/TODO.md index ca5f120..e92ae4e 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,7 @@ - [x] Make dropdowns a singleton component (work in progress) - [ ] Make the sidebar better :kekdoggo:. It's annoying to manage across routes - [x] Make topics manually renameable +- [ ] Surface auto-rename failures through the future toast/error UI; add actionable retry and bounded bulk naming for unnamed/provisional topics - [ ] Improve latex rendering, make single line equations work _well_, and likely make it configurable - [x] Implement appearence settings - [x] Accent colors diff --git a/app/assets/css/base.css b/app/assets/css/base.css index 635a643..b68586e 100644 --- a/app/assets/css/base.css +++ b/app/assets/css/base.css @@ -318,16 +318,19 @@ button.accent:hover { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18'%3E%3Cpath fill='none' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0'/%3E%3C/svg%3E"); } -.reasoning-container.middle { - mask-image: linear-gradient(#000, #000, transparent 0, #000 12%, #000 88%, transparent) +.reasoning-container.top { + -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 24px), transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 24px), transparent 100%); } -.reasoning-container.top { - mask-image: linear-gradient(#000, transparent, #000 0, #000 12%, #000 88%, transparent) +.reasoning-container.middle { + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 calc(100% - 24px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 calc(100% - 24px), transparent 100%); } .reasoning-container.bottom { - mask-image: linear-gradient(transparent, #000, transparent 0, #000 12%, #000 88%, #000) + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 100%); + mask-image: linear-gradient(to bottom, transparent 0%, #000 24px, #000 100%); } /* Markdown renderer */ diff --git a/app/components/ChatInput.vue b/app/components/ChatInput.vue index bdee850..ed8c0c1 100644 --- a/app/components/ChatInput.vue +++ b/app/components/ChatInput.vue @@ -4,11 +4,17 @@ import { computed, onMounted, ref, watch, onUnmounted, nextTick, type Ref } from import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels'; import type { Agent } from '~/composables/useAgents'; import type FileSelector from './FileSelector.vue'; +import { getComposerHeight } from '~/utils/composer-size'; const { allModels } = await useModels(); const { updateAgent, patchAgentLocally } = await useAgents(); -const inputHeight: Ref = ref('auto'); +const isDrawerOpen = useCookie("chat:tools-drawer", { default: () => false }); +watch(isDrawerOpen, () => { + emit("resize"); +}); + +const inputHeight: Ref = ref('48px'); const fileSelectorRef = ref | null>(null); const inputRef = ref(null); let tempInput = ''; @@ -303,47 +309,57 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => { }; const mirrorRef = ref(null); +const MIRROR_PROPERTIES = [ + 'box-sizing', 'font-family', 'font-size', 'font-weight', 'font-style', + 'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-indent', + 'text-transform', 'tab-size', 'padding-top', 'padding-bottom', 'padding-left', + 'padding-right', 'border-top-width', 'border-bottom-width', 'border-left-width', + 'border-right-width', 'border-style', 'white-space', 'overflow-wrap', 'word-break', +] as const; +let sizingRaf: number | undefined; +let sizingObserver: ResizeObserver | undefined; +let typographyObserver: MutationObserver | undefined; +let sizingDirty = true; +let mirrorWidth = ''; +let lineHeight = 24; +let padding = 0; +let borders = 0; +let borderBox = true; const resizeTextArea = () => { - const textarea = inputRef.value; - const mirror = mirrorRef.value; - if (!textarea || !mirror) return; + if (import.meta.server || sizingRaf !== undefined) return; + sizingRaf = requestAnimationFrame(() => { + sizingRaf = undefined; + const textarea = inputRef.value; + const mirror = mirrorRef.value; + if (!textarea || !mirror) return; - emit('resize'); - nextTick().then(() => { - mirror.textContent = (textarea.value || '\u200b') + '\n'; + if (sizingDirty) { + // Finish style reads before changing the isolated measurement box. + const styles = getComputedStyle(textarea); + const values = MIRROR_PROPERTIES.map(property => styles.getPropertyValue(property)); + mirrorWidth = styles.width; + lineHeight = Number.parseFloat(styles.lineHeight) || Number.parseFloat(styles.fontSize) * 1.2; + padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom); + borders = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth); + borderBox = styles.boxSizing === 'border-box'; + MIRROR_PROPERTIES.forEach((property, index) => mirror.style.setProperty(property, values[index]!)); + mirror.style.width = mirrorWidth; + sizingDirty = false; + } - 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'; + // Preserve the final empty line without adding an extra line to ordinary text. + mirror.textContent = textarea.value + '\u200b'; + const height = `${getComposerHeight(mirror.scrollHeight, lineHeight, padding, borders, borderBox)}px`; + if (height === inputHeight.value) return; - const lineHeight = 24; - const minLines = 2; - const maxLines = 10; - const minHeight = minLines * lineHeight; - const maxHeight = maxLines * lineHeight; - const height = Math.min(Math.max(mirror.scrollHeight, minHeight), maxHeight); - inputHeight.value = `${height}px`; + // Capture bottom-follow intent before applying the new height. + emit('resize'); + inputHeight.value = height; }); }; -watch(textAreaValue, resizeTextArea, { immediate: true }); +watch(textAreaValue, resizeTextArea, { flush: 'post' }); let hasCommandKey = false; if (import.meta.server) { @@ -357,41 +373,51 @@ onBeforeMount(() => { tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? ''; }); -let resizeRafId: number | undefined; const handleWindowResize = () => { - if (resizeRafId) return; - resizeRafId = requestAnimationFrame(() => { - resizeRafId = undefined; - resizeTextArea(); - }); -} + sizingDirty = true; + resizeTextArea(); +}; onMounted(() => { textAreaValue.value = tempInput; document.addEventListener('keydown', handleWindowKeyDown); window.addEventListener('resize', handleWindowResize); inputRef.value?.addEventListener('paste', handlePaste); + let observedWidth: number | undefined; + sizingObserver = new ResizeObserver(([entry]) => { + if (!entry || entry.contentRect.width === observedWidth) return; + observedWidth = entry.contentRect.width; + handleWindowResize(); + }); + if (inputRef.value) sizingObserver.observe(inputRef.value); + typographyObserver = new MutationObserver(handleWindowResize); + typographyObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] }); + document.fonts.addEventListener('loadingdone', handleWindowResize); + handleWindowResize(); }); onUnmounted(() => { document.removeEventListener('keydown', handleWindowKeyDown); window.removeEventListener('resize', handleWindowResize); - if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId); + if (sizingRaf !== undefined) cancelAnimationFrame(sizingRaf); + sizingObserver?.disconnect(); + typographyObserver?.disconnect(); + document.fonts.removeEventListener('loadingdone', handleWindowResize); inputRef.value?.removeEventListener('paste', handlePaste); });