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
+7 -2
View File
@@ -1,4 +1,6 @@
import type { BaseMessage } from "~/composables/useChat";
import { nanoid } from "nanoid";
import { provisionalTopicTitle } from "~~/utils/topic-title";
import { attempt } from "~~/types/result";
import * as schema from '~~/drizzle/schema';
@@ -153,11 +155,11 @@ export const useAgents = async () => {
// TODO: since topics are contained within each agent struct, all topic
// actions must be done through the agents composable
const createTopic = async (agentId: string) => {
const createTopic = async (agentId: string, message?: BaseMessage) => {
const topicId = nanoid();
const topic = {
id: topicId,
name: 'New Topic',
name: provisionalTopicTitle(message),
agentId,
};
@@ -214,6 +216,9 @@ export const useAgents = async () => {
agentId: agent.id,
userId: agent.userId,
renaming: false,
renameAttemptId: null,
renameExpiresAt: null,
titleSource: "manual",
createdAt: new Date(),
};
agents.value = agents.value.map(a =>
+97 -23
View File
@@ -15,6 +15,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
let lastScrollTop = 0;
let mutationRaf: number | null = null;
let anchorRaf: number | null = null;
let anchorDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
let observer: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
@@ -26,15 +27,25 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
const setUnhooked = (value: boolean) => {
unhooked.value = value;
shouldAutoScroll.value = !value;
if (!value) anchor = null;
};
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
const el = elementRef.value;
if (!el) return;
if (!el) {
return;
}
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
if (Math.abs(el.scrollTop - bottom) < 0.5) {
setUnhooked(false);
lastScrollTop = el.scrollTop;
return;
}
isProgrammatic = true;
el.scrollTo({
top: el.scrollHeight,
top: bottom,
behavior,
});
@@ -48,13 +59,19 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
};
const captureAnchor = (el: HTMLElement) => {
if (!unhooked.value) {
anchor = null;
return;
}
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const offsets = [4, 24, 48, 80, 120];
for (const y of offsets) {
const target = document.elementFromPoint(x, rect.top + y);
if (!target || target === el || !el.contains(target)) continue;
if (!target || target === el || !el.contains(target)) {
continue;
}
anchor = {
element: target,
offsetFromTop: target.getBoundingClientRect().top - rect.top,
@@ -65,8 +82,8 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
anchor = null;
};
const scheduleAnchorCapture = () => {
if (anchorRaf !== null) return;
const requestAnchorCapture = () => {
if (!unhooked.value || anchorRaf !== null) return;
anchorRaf = requestAnimationFrame(() => {
anchorRaf = null;
const el = elementRef.value;
@@ -74,13 +91,38 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
});
};
const scheduleAnchorCapture = () => {
const el = elementRef.value;
// Native scrollend is the primary path; debounce is only a fallback.
if (!el || 'onscrollend' in el || !unhooked.value) return;
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
}
anchorDebounceTimeout = setTimeout(() => {
anchorDebounceTimeout = null;
requestAnchorCapture();
}, 150);
};
const handleScrollEnd = () => {
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
requestAnchorCapture();
};
const restoreAnchor = (el: HTMLElement) => {
if (!anchor || !el.contains(anchor.element)) return;
if (!anchor || !el.contains(anchor.element)) {
return;
}
const top = el.getBoundingClientRect().top;
const delta =
anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop;
if (Math.abs(delta) < 0.5) return;
if (Math.abs(delta) < 0.5) {
return;
}
isProgrammatic = true;
el.scrollTop += delta;
@@ -99,37 +141,54 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
const handleScroll = () => {
const el = elementRef.value;
if (!el) return;
if (!el) {
return;
}
const scrollingUp = el.scrollTop < lastScrollTop;
lastScrollTop = el.scrollTop;
scheduleAnchorCapture();
if (isProgrammatic) {
if (isAtBottom(el)) setUnhooked(false);
if (isAtBottom(el)) {
setUnhooked(false);
}
return;
}
// Only re-hook when actually inside the bottom threshold.
// Mid-page scroll (up or down) must never re-enable stick.
// If the user is scrolling up, they are deliberately navigating away from the bottom.
// Unhook immediately so we never fight the scroll or snap back down.
if (scrollingUp) {
setUnhooked(true);
scheduleAnchorCapture();
return;
}
// Only re-hook when scrolling down into the bottom threshold.
if (isAtBottom(el)) {
setUnhooked(false);
return;
}
if (scrollingUp) {
setUnhooked(true);
}
scheduleAnchorCapture();
};
const followIfHooked = () => {
if (unhooked.value) return;
if (unhooked.value) {
return;
}
scrollToBottom('instant');
};
const handleMutation = () => {
if (mutationRaf !== null) return;
const handleMutation = (records: MutationRecord[]) => {
if (unhooked.value || mutationRaf !== null) {
return;
}
// The isolated sizing mirror changes on every keystroke, but cannot
// change scroll height. Keep observing real composer content (files,
// tool controls, etc.) as well as message content.
if (records.every(record => {
const target = record.target instanceof Element ? record.target : record.target.parentElement;
return target?.closest('[data-chat-measurement]');
})) return;
mutationRaf = requestAnimationFrame(() => {
mutationRaf = null;
followIfHooked();
@@ -144,6 +203,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
el.addEventListener('wheel', handleWheel, { passive: true });
el.addEventListener('scroll', handleScroll, { passive: true });
el.addEventListener('scrollend', handleScrollEnd, { passive: true });
observer = new MutationObserver(handleMutation);
observer.observe(el, {
@@ -154,7 +214,9 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
resizeObserver = new ResizeObserver(() => {
const current = elementRef.value;
if (!current) return;
if (!current) {
return;
}
const width = current.clientWidth;
if (width === lastWidth) {
@@ -172,13 +234,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
return;
}
if (!anchor) {
captureAnchor(current);
}
restoreAnchor(current);
});
resizeObserver.observe(el);
// Content already taller than the viewport on mount — catch up once
requestAnimationFrame(() => {
if (!unhooked.value) scrollToBottom('instant');
if (!unhooked.value) {
scrollToBottom('instant');
}
});
};
@@ -186,6 +253,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
if (el) {
el.removeEventListener('wheel', handleWheel);
el.removeEventListener('scroll', handleScroll);
el.removeEventListener('scrollend', handleScrollEnd);
}
observer?.disconnect();
observer = null;
@@ -199,12 +267,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
cancelAnimationFrame(anchorRaf);
anchorRaf = null;
}
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
anchor = null;
};
watch(elementRef, (newEl, oldEl) => {
detach(oldEl ?? null);
if (newEl) attach(newEl);
if (newEl) {
attach(newEl);
}
}, { immediate: true });
onUnmounted(() => {
+161 -52
View File
@@ -1,7 +1,9 @@
import { nanoid } from 'nanoid';
import { toRaw } from 'vue';
import * as schema from '~~/drizzle/schema';
import { Err, Ok, type Result } from '~~/types/result';
import { buildMessageTree } from '~~/utils/message';
import { appendTextDelta, serializeTopicSnapshot } from '~~/utils/topic-sync';
export type BaseMessage = {
content: string;
@@ -38,7 +40,7 @@ export enum ChatErrorType {
Unimplemented,
}
type Event = { type: string; payload: any; timestamp: number };
type Event = { type: string; payload: any; timestamp: number; replay?: boolean };
const TARGET_UPDATES_PER_SECOND = 24;
@@ -46,6 +48,10 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined;
let disposed = false;
let connectionAttempt = 0;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
const { promise: connectionReady, resolve: markConnected } = Promise.withResolvers<void>();
const textDeltaBuffer: Map<string, Map<string, {
content: string;
@@ -64,7 +70,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
for (const [partId, content] of parts) {
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content.content;
part.content = content.content;
part.lastUpdatedAt = new Date(content.lastUpdatedAt);
}
}
@@ -93,6 +99,23 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
};
const resetTextDeltas = () => {
if (flushTimeout !== undefined) clearTimeout(flushTimeout);
flushTimeout = undefined;
textDeltaBuffer.clear();
flushCbs.clear();
};
const scheduleReconnect = (forceSnapshot = false) => {
sse?.close();
sse = undefined;
if (disposed || reconnectTimeout !== undefined) return;
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined;
void connectSSE(forceSnapshot);
}, 1500);
};
const processEvent = (event: Event) => {
if (!data.value) return;
@@ -102,12 +125,17 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'MESSAGE_CREATED': {
// Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] });
data.value.messages.push({ ...payload, parts: payload.parts ?? [], attachments: payload.attachments ?? [] });
} else {
// replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) {
data.value.messages[index] = { ...payload, parts: [] };
if (index !== -1 && !event.replay) {
const existing = data.value.messages[index]!;
data.value.messages[index] = {
...payload,
parts: payload.parts ?? [],
attachments: payload.attachments ?? existing.attachments ?? [],
};
}
}
break;
@@ -130,7 +158,9 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
if (!msg.parts.some(part => part.id === payload.part.id)) {
msg.parts.push(payload.part);
}
}
break;
}
@@ -138,18 +168,33 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break;
const part = msg.parts?.find(part => part.id === payload.partId);
if (!part || part.finished) break;
if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map());
}
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId)?.content || '';
messageBuffer.set(payload.partId, { content: existing + payload.content, lastUpdatedAt: payload.lastUpdatedAt });
const existing = messageBuffer.get(payload.partId)?.content ?? part.content ?? '';
if (typeof payload.offset === 'number' && payload.offset > existing.length) {
scheduleReconnect(true);
break;
}
const content = appendTextDelta(existing, payload.content, payload.offset);
if (content === existing) break;
messageBuffer.set(payload.partId, { content, lastUpdatedAt: payload.lastUpdatedAt });
scheduleFlush();
break;
}
case 'text-delete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg?.parts) msg.parts = msg.parts.filter(part => part.id !== payload.partId);
textDeltaBuffer.get(payload.messageId)?.delete(payload.partId);
break;
}
case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId);
@@ -175,7 +220,9 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
if (!part.createdAt) {
part.createdAt = new Date().toISOString();
}
msg.parts.push(part);
if (!msg.parts.some(existing => existing.id === part.id)) {
msg.parts.push(part);
}
}
break;
}
@@ -229,55 +276,79 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
}
const connectSSE = async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const connectSSE = async (forceSnapshot = false) => {
if (disposed) return;
const attempt = ++connectionAttempt;
const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0';
const msgCount = data.value?.messages.length || 0;
if (sse && sse.readyState !== EventSource.CLOSED) {
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) {
sse.close();
} else {
return;
sse?.close();
if (flushTimeout !== undefined) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
let offeredSnapshot: string | undefined;
const query = new URLSearchParams();
if (!forceSnapshot && data.value?.id === id && globalThis.crypto?.subtle) {
try {
offeredSnapshot = serializeTopicSnapshot(toRaw(data.value));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(offeredSnapshot));
query.set('snapshot', Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''));
} catch {
// Fingerprinting is an optimization; a full snapshot remains safe.
offeredSnapshot = undefined;
}
}
if (disposed || attempt !== connectionAttempt) return;
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
const source = new EventSource(`/api/topic/${id}?${query}`);
sse = source;
source.addEventListener('error', () => {
if (sse === source) scheduleReconnect();
});
sse.addEventListener("error", (e) => {
console.error('sse error', e);
setTimeout(connectSSE, 1500);
})
sse.addEventListener("message", (e) => {
source.addEventListener('message', (e) => {
if (disposed || sse !== source) return;
const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw];
for (const { type, payload, timestamp } of events) {
for (const event of events) {
const { type, payload } = event;
if (!type || !payload) continue;
if (type === 'initial_state') {
data.value = payload;
resolve()
if (type === 'synced') {
// Optimistic edits can occur while the handshake is in flight.
// Only retain the exact state that we offered to the server.
if (offeredSnapshot !== serializeTopicSnapshot(toRaw(data.value))) {
scheduleReconnect(true);
return;
}
offeredSnapshot = undefined;
markConnected();
continue;
}
processEvent({ type, payload, timestamp });
if (type === 'initial_state') {
resetTextDeltas();
data.value = payload;
offeredSnapshot = undefined;
markConnected();
continue;
}
processEvent(event);
}
});
await promise;
}
await connectionReady;
};
if (connect) {
onBeforeUnmount(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
disposed = true;
connectionAttempt++;
if (reconnectTimeout !== undefined) clearTimeout(reconnectTimeout);
resetTextDeltas();
markConnected();
if (sse) {
console.log('[SSE] Closing connection');
sse.close();
@@ -525,38 +596,76 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
if (!data.value) return Err(new Error('No data'));
if (!data.value) {
return Err(new Error("No data"));
}
const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) return Err(new Error('Message not found'));
const msg = data.value.messages.find((m) => {
return m.id === messageId;
});
if (!msg) {
return Err(new Error("Message not found"));
}
const previousMessages = data.value!.messages;
const previousMessages = data.value.messages;
const res = await $fetch(`/api/messages/${messageId}`, {
method: 'DELETE',
method: "DELETE",
onRequest() {
if (!msg.activeChildId && (msg.parentMessageId === null || msg.parentMessageId === undefined)) {
msg.deleted = true;
if (!data.value) {
return;
}
const parent = data.value!.messages.find(m => m.id === msg.parentMessageId);
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id);
if (parent?.deleted) {
data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id);
if (msg.parentMessageId) {
const parent = data.value.messages.find((m) => {
return m.id === msg.parentMessageId;
});
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
if (parent) {
const remainingChildren = data.value.messages.filter((m) => {
return m.parentMessageId === parent.id;
});
if (parent.deleted && remainingChildren.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== parent.id;
});
} else if (parent.activeChildId === msg.id) {
parent.activeChildId = remainingChildren.length > 0
? (parent.deleted ? remainingChildren[0]!.id : remainingChildren[remainingChildren.length - 1]!.id)
: null;
}
}
return;
}
const children = data.value.messages.filter((m) => {
return m.parentMessageId === msg.id;
});
if (children.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
return;
}
msg.deleted = true;
msg.activeChildId = children[0]?.id ?? null;
},
onResponseError() {
data.value!.messages = previousMessages;
if (data.value) {
data.value.messages = previousMessages;
}
},
});
if (!res.ok) {
return Err(new Error('Failed to delete message'));
return Err(new Error("Failed to delete message"));
}
return Ok(undefined);
}
};
return {
topic,
-2
View File
@@ -8,8 +8,6 @@ export const useTokenDropdown = () => {
const triggerElement = useState<HTMLElement | null>('token-dropdown:trigger', () => null);
const open = (generation: Generation, trigger: HTMLElement) => {
console.log("open", generation, trigger);
activeGeneration.value = generation;
triggerElement.value = trigger;
isOpen.value = true;
+9 -20
View File
@@ -10,7 +10,7 @@ export const useTopic = () => {
FailedToDecryptProviderApiKey,
FailedToGenerate,
}
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
const autoRename = async (topicId: string): Promise<Result<void, AutoRenameError>> => {
const { settings } = await useUserSettings();
if (!settings.value.systemAssistants.rename.enabled) {
@@ -54,7 +54,7 @@ export const useTopic = () => {
}
}
const { agents } = await useAgents();
const { patchTopicLocally, refresh } = await useAgents();
try {
// @ts-ignore - excessive stack depth
@@ -65,31 +65,20 @@ export const useTopic = () => {
providerApiKey,
}),
onRequest() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
});
}
patchTopicLocally(topicId, { renaming: true });
},
onRequestError() {
const topics = agents.value.flatMap(agent => agent.topics);
const topic = topics.find(topic => topic.id === topicId);
if (topic) {
agents.value = agents.value.map(agent => {
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
});
}
},
}) as { ok: true, renameId: string } | { ok: false, code: string };
});
if (!res.ok) {
patchTopicLocally(topicId, { renaming: false });
await refresh();
console.error('Failed to auto-rename:', res.code);
return Err(AutoRenameError.FailedToGenerate);
}
return Ok(res.renameId);
return Ok(undefined);
} catch (error) {
patchTopicLocally(topicId, { renaming: false });
await refresh();
console.error('Failed to auto-rename:', error);
return Err(AutoRenameError.FailedToGenerate);
}
+38 -7
View File
@@ -1,11 +1,14 @@
export const useUserEvents = () => {
const eventSource = ref<EventSource | null>(null);
const agents = useState<AgentWithTopics[]>("agents_state", () => []);
const isConnected = useState('userEvents:connected', () => false);
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
const reconnectAttempts = ref(0);
const connectionStartTime = ref<number>(0);
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
let reconciliationTimer: ReturnType<typeof setInterval> | undefined;
let reconciling = false;
const BASE_DELAY = 1000;
const MAX_DELAY = 30000;
@@ -50,7 +53,6 @@ export const useUserEvents = () => {
cleanup();
connectionStartTime.value = Date.now();
reconnectAttempts.value = 0;
const source = new EventSource('/api/events');
eventSource.value = source;
@@ -59,6 +61,7 @@ export const useUserEvents = () => {
console.log('User events connected');
isConnected.value = true;
reconnectAttempts.value = 0;
void reconcile();
};
source.onerror = () => {
@@ -164,20 +167,48 @@ export const useUserEvents = () => {
};
const reconcile = async () => {
console.log('Reconciling user state...');
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
if (data.value) {
const { agents } = await useAgents();
agents.value = data.value;
if (reconciling) {
return;
}
reconciling = true;
try {
agents.value = await $fetch<AgentWithTopics[]>("/api/agents");
} catch (error) {
console.error("Failed to reconcile topics:", error);
} finally {
reconciling = false;
}
};
const resume = () => {
if (document.visibilityState !== "visible") {
return;
}
if (!isConnected.value) {
reconnectAttempts.value = 0;
void connect();
}
void reconcile();
};
onMounted(() => {
connect();
void connect();
document.addEventListener("visibilitychange", resume);
window.addEventListener("online", resume);
window.addEventListener("pageshow", resume);
reconciliationTimer = setInterval(() => {
if (document.visibilityState === "visible" && agents.value.some(agent => agent.topics.some(topic => topic.renaming))) {
void reconcile();
}
}, 15000);
});
onBeforeUnmount(() => {
cleanup();
clearInterval(reconciliationTimer);
document.removeEventListener("visibilitychange", resume);
window.removeEventListener("online", resume);
window.removeEventListener("pageshow", resume);
});
return {