From 2b9f8d64d7c201a928ab286881d40e62dbc2b9c9 Mon Sep 17 00:00:00 2001 From: Zoe Date: Sun, 27 Sep 2026 18:44:51 -0500 Subject: [PATCH] bug fixes, and performance improvements, and drawer --- TODO.md | 1 + app/assets/css/base.css | 13 +- app/components/ChatInput.vue | 249 +- app/components/Markdown/Renderer.vue | 25 +- app/components/Markdown/ShikiHighlight.vue | 4 - app/components/Markdown/SourceCitation.vue | 123 + app/components/Message/Agent/Reasoning.vue | 43 +- .../Message/Agent/ReasoningBody.vue | 65 + app/components/Message/Agent/Text.vue | 11 - app/components/Message/Agent/index.vue | 30 +- app/components/Message/index.vue | 180 +- app/components/Sidenav/NavAgent.vue | 19 +- app/components/Tooltip.vue | 14 +- app/composables/useAgents.ts | 9 +- app/composables/useAutoScroll.ts | 120 +- app/composables/useChat.ts | 213 +- app/composables/useTokenDropdown.ts | 2 - app/composables/useTopic.ts | 29 +- app/composables/useUserEvents.ts | 45 +- app/layouts/default.vue | 5 +- app/pages/agent/[id]/index.vue | 9 +- app/pages/agent/[id]/topic/[topicId].vue | 145 +- app/pages/index.vue | 2 +- app/utils/citations.ts | 45 + app/utils/composer-size.ts | 11 + app/utils/markdown.ts | 153 +- app/utils/tool-rendering.ts | 6 +- .../migration.sql | 5 + .../snapshot.json | 2795 +++++++++++++++++ drizzle/schema.ts | 3 + server/api/agents.get.ts | 13 + .../api/messages/[messageId]/index.delete.ts | 104 +- .../[topicId]/auto-rename/cancel.post.ts | 73 +- .../topic/[topicId]/auto-rename/index.post.ts | 135 +- server/api/topic/[topicId]/chat/index.post.ts | 20 +- server/api/topic/[topicId]/index.get.ts | 109 +- server/api/topic/[topicId]/index.patch.ts | 6 + server/api/topic/index.post.ts | 1 + server/utils/events.ts | 20 +- server/utils/live-topic-text.ts | 65 + server/utils/renames.ts | 40 +- server/utils/search.ts | 3 + server/utils/topic-snapshot-stream.ts | 70 + tests/citations.test.ts | 84 + tests/composer-size.test.ts | 25 + tests/markdown-transform.test.ts | 52 + tests/message-focus.test.ts | 190 ++ tests/topic-rename.test.ts | 42 + tests/topic-sync.test.ts | 131 + utils/message.ts | 104 +- utils/topic-sync.ts | 21 + utils/topic-title.ts | 9 + 52 files changed, 4913 insertions(+), 778 deletions(-) create mode 100644 app/components/Markdown/SourceCitation.vue create mode 100644 app/components/Message/Agent/ReasoningBody.vue delete mode 100644 app/components/Message/Agent/Text.vue create mode 100644 app/utils/citations.ts create mode 100644 app/utils/composer-size.ts create mode 100644 drizzle/migrations/20260927154018_topic-rename-leases/migration.sql create mode 100644 drizzle/migrations/20260927154018_topic-rename-leases/snapshot.json create mode 100644 server/utils/live-topic-text.ts create mode 100644 server/utils/topic-snapshot-stream.ts create mode 100644 tests/citations.test.ts create mode 100644 tests/composer-size.test.ts create mode 100644 tests/markdown-transform.test.ts create mode 100644 tests/message-focus.test.ts create mode 100644 tests/topic-rename.test.ts create mode 100644 tests/topic-sync.test.ts create mode 100644 utils/topic-sync.ts create mode 100644 utils/topic-title.ts 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); });