bug fixes, and performance improvements, and drawer

This commit is contained in:
Zoe
2026-09-27 18:44:51 -05:00
parent d48d8163a7
commit 2b9f8d64d7
52 changed files with 4913 additions and 778 deletions
+17 -8
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { MarkdownAstNode, MarkdownElementNode } from '~/utils/markdown';
import { createMarkdownParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
import { createTextVNode, h, ref, watch } from 'vue';
import type { MarkdownAstNode } from '~/utils/markdown';
import { agentParser, userParser, getNodeAttributes, getNodeChildren, getNodeTag, splitParagraphBreaks, textContent, toVueAttributes } from '~/utils/markdown';
import { createTextVNode, h, shallowRef, watch } from 'vue';
import type { SearchSource } from '~/utils/tool-rendering';
import SourceCitation from './SourceCitation.vue';
import MarkdownMath from './Math.vue';
import MarkdownShikiHighlight from './ShikiHighlight.vue';
@@ -9,15 +11,19 @@ const props = defineProps<{
content: string;
finished: boolean;
id: string;
sources?: ReadonlyMap<string, SearchSource>;
citations?: boolean;
}>();
const parser = createMarkdownParser();
const parser = props.citations ? agentParser : userParser;
const parse = async (content: string, finished: boolean) => {
const tree = await parser(content, { streaming: !finished });
// Copy-on-write: br-free trees return by reference with no allocation.
return splitParagraphBreaks(tree);
};
const parsed = ref(await parse(props.content, props.finished));
// Parsed trees are replaced, never mutated. Avoid proxying every AST node.
const parsed = shallowRef(await parse(props.content, props.finished));
let parseVersion = 0;
watch(
@@ -46,6 +52,11 @@ const renderNode = (node: MarkdownAstNode, path: string): any => {
const tag = getNodeTag(node);
const attributes = getNodeAttributes(node);
if (tag === 'source-cite') {
const ids = typeof attributes.ids === "string" ? attributes.ids.split(",") : [];
return h(SourceCitation, { key: path, ids, sources: props.sources });
}
if (tag === 'pre') {
const language = typeof attributes.language === 'string' ? attributes.language : 'text';
return h(MarkdownShikiHighlight, {
@@ -79,9 +90,7 @@ const render = () => {
.map((node, index) => renderNode(node, `${props.id}.${index}`))
.filter((child) => child !== null);
return h('div', { class: 'prose-wrapper' }, [
h('article', { class: 'markdown-body' }, children),
]);
return h('article', { class: 'prose-wrapper markdown-body' }, children);
};
</script>
@@ -3,8 +3,6 @@ import { type Grammar } from 'shiki';
import { hashSync } from '~/utils/hash';
const props = defineProps<{ code: string; language: string }>();
const start = Date.now();
const MAX_LENGTH = 15000;
const renderId = `${useId()}-${hashSync(props.code + props.language)}`;
@@ -76,8 +74,6 @@ onUnmounted(() => {
if (copyTimeout) clearTimeout(copyTimeout);
clear();
});
console.log("shiki codeblock rendered in", Date.now() - start);
</script>
<template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { SearchSource } from "~/utils/tool-rendering";
import { autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/vue";
import { computed, onBeforeUnmount, ref, useId } from "vue";
import { getFaviconUrl } from "~/utils/tool-rendering";
const props = defineProps<{
ids: string[];
sources?: ReadonlyMap<string, SearchSource>;
}>();
const entries = computed(() => [...new Set(props.ids)].map(id => ({ id, source: props.sources?.get(id) })));
const firstSource = computed(() => entries.value.find(entry => entry.source)?.source);
const label = computed(() => {
const first = firstSource.value?.hostname ?? "Source unavailable";
return entries.value.length > 1 ? `${first} +${entries.value.length - 1}` : first;
});
const popupId = useId();
const trigger = ref<HTMLElement | null>(null);
const preview = ref<HTMLElement | null>(null);
const open = ref(false);
const failedFavicons = ref(new Set<string>());
const { floatingStyles, placement } = useFloating(trigger, preview, {
open,
strategy: "fixed",
placement: "top-start",
middleware: [offset(8), flip(), shift({ padding: 12 })],
whileElementsMounted: autoUpdate,
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith("top")
? "transform-origin-bottom-center"
: "transform-origin-top-center"
);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let pointerInside = false;
const cancelClose = () => {
clearTimeout(closeTimer);
closeTimer = undefined;
};
const showPreview = () => {
cancelClose();
open.value = true;
};
const scheduleClose = () => {
cancelClose();
// Allow the pointer to cross the gap between the badge and the preview.
closeTimer = setTimeout(() => {
closeTimer = undefined;
const focused = document.activeElement;
if (!pointerInside && !trigger.value?.contains(focused) && !preview.value?.contains(focused)) {
open.value = false;
}
}, 150);
};
const onPointerEnter = (event: PointerEvent) => {
if (event.pointerType === "touch") {
return;
}
pointerInside = true;
showPreview();
};
const onPointerLeave = (event: PointerEvent) => {
if (event.pointerType === "touch") {
return;
}
pointerInside = false;
scheduleClose();
};
const dismissPreview = () => {
cancelClose();
if (preview.value?.contains(document.activeElement)) {
trigger.value?.focus();
}
open.value = false;
};
onBeforeUnmount(cancelClose);
</script>
<template>
<a ref="trigger" :href="firstSource?.url" target="_blank" rel="noopener noreferrer"
:tabindex="firstSource ? undefined : 0" :aria-expanded="open" :aria-controls="open ? popupId : undefined"
@pointerenter="onPointerEnter" @pointerleave="onPointerLeave" @focusin="showPreview" @focusout="scheduleClose"
@keydown.esc.stop.prevent="dismissPreview"
class="inline-flex items-center gap-1 max-w-64 mx-1 px-2 border border-[var(--color-border)] rounded-full bg-[var(--bg-container)] hover:bg-[var(--color-hover)] transition-colors text-[var(--text-secondary)] text-xs align-baseline cursor-pointer focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--text-secondary)] focus-visible:outline-offset-2 decoration-none">
<img v-if="firstSource && !failedFavicons.has(firstSource.hostname)" :src="getFaviconUrl(firstSource.hostname)"
alt="" loading="lazy" class="rounded-[4px] w-[0.9rem] h-[0.9rem] m-0"
@error="failedFavicons.add(firstSource.hostname)">
{{ label }}
</a>
<Transition enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
<span v-if="open" :id="popupId" ref="preview" @pointerenter="onPointerEnter" @pointerleave="onPointerLeave"
@focusin="showPreview" @focusout="scheduleClose" @keydown.esc.stop.prevent="dismissPreview"
class="z-2 inset-auto m-0 p-[0.85rem] w-[min(22rem,calc(100vw_-_24px))] max-h-[min(24rem,70vh)] overflow-y-auto border border-[var(--color-border)] rounded-xl bg-[var(--bg-surface)] text-[var(--text-primary)] shadow-md text-sm leading-normal"
:class="transformOrigin" :style="floatingStyles">
<span v-for="(entry, index) in entries" :key="entry.id"
class="flex flex-col gap-[0.4rem] [overflow-wrap:anywhere]"
:class="{ 'mt-[0.8rem] pt-[0.8rem] border-t border-[var(--color-border)]': index > 0 }">
<template v-if="entry.source">
<span class="text-xs text-[var(--text-secondary)]">{{ entry.source.hostname }} · Search
excerpt</span>
<a :href="entry.source.url" target="_blank" rel="noopener noreferrer">{{ entry.source.title }}</a>
<span v-if="entry.source.snippet" class="whitespace-pre-wrap text-[var(--text-secondary)]">{{
entry.source.snippet }}</span>
</template>
<span v-else>Source unavailable ({{ entry.id }})</span>
</span>
</span>
</Transition>
</template>