feat: add better provider support, icons, regen, and a lot more

This commit is contained in:
Zoe
2026-02-12 14:56:13 +00:00
parent d5a5945c03
commit d29f95bacf
124 changed files with 6374 additions and 1861 deletions
+21 -13
View File
@@ -1,23 +1,31 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown';
import { authClient } from '~~/lib/auth-client';
import { assert } from '~~/utils/assert';
const triplit = useTriplitClient();
const { user } = await useAuth();
const { user, signOut } = useAuth();
// to prevent the user details from going blank for a
// single frame when the user logs out (yes I am that
// particular)
const cachedUser = ref(user.value);
watch(user, () => {
if (user.value === null) return;
cachedUser.value = user.value;
})
const { toggle: toggleSettings } = useSettings();
const hovering = defineModel<boolean>({ required: true });
const { isHovered } = useSidenavContext();
const profileOpen = ref(false);
const handleLogout = async () => {
await authClient.signOut();
if ('endSession' in triplit) {
await triplit.endSession();
}
clearNuxtData();
await signOut();
assert('disconnect' in triplit);
triplit.disconnect();
await navigateTo('/auth/login');
};
@@ -43,15 +51,15 @@ const profileItems: DropdownItem[] = [
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="user?.image" :src="user.image" class="w-full h-full object-cover" />
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-muted)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
user!.name
cachedUser?.name
}}</span>
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-muted)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-x-0 scale-y-90'
<div :class="['transform-origin-left-center flex-shrink-0 w-4 h-4 text-[var(--color-muted)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] overflow-hidden transform-origin-center-left',
isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-40'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
+5 -38
View File
@@ -2,47 +2,14 @@
import type { DropdownItem } from '~/types/dropdown';
const route = useRoute();
const { agents, getAgent } = await useAgents();
const homeButtonRef = ref<HTMLElement | null>(null);
const { agents, getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const activeAgent = computed(() => getAgent(route.params.id as string));
const hovering = defineModel<boolean>({ required: true });
const initialized = ref(false);
let lastHovering: boolean | null = null;
onMounted(() => {
console.log(hovering.value);
if (hovering.value && homeButtonRef.value) {
const width = homeButtonRef.value.scrollWidth;
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
}
watch(hovering, (value) => {
if (lastHovering === value) {
console.warn('Hovering value did not change, but watcher was triggered');
}
console.log(value, lastHovering);
lastHovering = value;
if (!initialized.value) {
initialized.value = true;
}
if (!homeButtonRef.value) return;
if (value) {
const width = homeButtonRef.value.scrollWidth;
homeButtonRef.value.style.width = `calc(${width}px + 0.5rem)`;
} else {
homeButtonRef.value.style.width = '0';
}
});
});
const { isHovered } = useSidenavContext();
onUnmounted(() => {
console.log('unmounted');
unsubscribeAgents?.();
});
const agentDropdownOpen = ref(false);
@@ -52,8 +19,8 @@ const agentItems: DropdownItem[] = [];
<template>
<header class="flex items-center rounded-lg overflow-hidden">
<div ref="homeButtonRef" style="width: 0;"
:class="['flex flex-shrink-0 items-center overflow-hidden', initialized ? 'transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]' : '', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<div :style="isHovered ? 'width: 32px;' : 'width: 0px;'"
:class="['flex flex-shrink-0 transform-origin-left-center items-center overflow-hidden transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]', isHovered ? 'opacity-100 scale-100' : 'opacity-0 scale-95']">
<NuxtLink to="/"
class="flex hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] rounded-lg decoration-none transition-inherit text-[var(--color-muted)] p-1.5">
<Icon name="mynaui:chevron-left" class="w-4.5 h-4.5" />
+71 -32
View File
@@ -4,7 +4,7 @@ const topicsListRef = ref<HTMLElement | null>(null);
const topicsListHeight = ref('auto');
const topicsListOpacity = ref(1);
const topicsListScale = ref(1);
const { getAgent } = await useAgents();
const { getAgent, unsubscribe: unsubscribeAgents } = await useAgents();
const triplit = useTriplitClient();
@@ -88,6 +88,19 @@ const toggleAgentsList = () => {
requestAnimationFrame(animate);
};
const autoRenameTopic = async (topicId: string) => {
const { setPage } = useSettings();
const { autoRename } = useChat(route.params.id as string);
const firstMessage = await triplit.fetchOne(triplit.query('messages').Where('topicId', '=', topicId).Order('createdAt', 'ASC').Limit(1));
if (!firstMessage) return;
const success = await autoRename(topicId, firstMessage.content);
if (!success) {
setPage('systemAssistants');
}
}
const renameTopic = (topicId: string) => {
console.log('renameTopic', topicId);
};
@@ -101,7 +114,13 @@ const deleteTopic = async (topicId: string) => {
}
}
await triplit.delete('topics', topicId);
// TODO: deeply delete all messages, generations, and message_parts in the topic
};
onUnmounted(() => {
unsubscribeAgents?.();
});
</script>
<template>
@@ -119,38 +138,58 @@ const deleteTopic = async (topicId: string) => {
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
</button>
<div ref="topicsListRef" :inert="!topicsOpen"
<div ref="topicsListRef" :inert="!topicsOpen" :class="{ 'overflow-y-hidden': !topicsOpen }"
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
class="mt-1 gap-1 flex flex-col transform-origin-center-top overflow-y-hidden">
<SidenavItem draggable="false" class="[&>div>div>div>[dots]]:hover:opacity-100 relative"
v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :active="topic.id === route.params.topicId"
:name="topic.name" :key="topic.id">
<Dropdown class="shrink-0" verticality="descending" placement="right">
<template #trigger="{ toggle, isOpen }">
<div dots @click.prevent.stop="toggle"
class="opacity-0 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
</div>
</template>
<template #content>
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
<button @click.prevent="renameTopic(topic.id)"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Rename
</button>
<button @click.prevent="deleteTopic(topic.id)"
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Delete
</button>
</div>
</template>
</Dropdown>
</SidenavItem>
class="mt-1 gap-1 flex flex-col transform-origin-center-top">
<NuxtLink v-if="activeAgent?.topics !== undefined" v-for="topic in topics"
:to="`/agent/${activeAgent.id}/topic/${topic.id}`" :aria-label="topic.name" :class="[
'group relative decoration-none text-[var(--color-muted)] flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9',
'px-2',
topic.id === route.params.topicId
? 'text-[var(--color-text)] bg-[var(--color-highlight)] hover:bg-[var(--color-highlight-high)] focus-visible:bg-[var(--color-highlight-high)]'
: 'hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)]'
]">
<div class="flex items-center gap-2 max-w-full flex-1">
<div v-if="!topic.renaming" class="flex justify-between items-center w-full">
<span class="text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
{{ topic.name }}
</span>
<Dropdown class="shrink-0 text-[var(--color-text)]" verticality="descending"
placement="right">
<template #trigger="{ toggle }">
<div dots @click.prevent="toggle"
class="opacity-0 group-hover:opacity-100 p-1 flex items-center justify-center rounded-md hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24"><!-- Icon from Solar by 480 Design - https://creativecommons.org/licenses/by/4.0/ -->
<path fill="currentColor"
d="M7 12a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0m7 0a2 2 0 1 1-4 0a2 2 0 0 1 4 0" />
</svg>
</div>
</template>
<template #content="{ toggle }">
<div class="shadow-lg rounded p-1 flex flex-col min-w-[120px] gap-1">
<button @click.prevent="autoRenameTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Auto Rename
</button>
<button @click.prevent="renameTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm hover:bg-[var(--color-highlight)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Rename
</button>
<button @click.prevent="deleteTopic(topic.id); toggle()"
class="text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-600/20 rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
Delete
</button>
</div>
</template>
</Dropdown>
</div>
<div v-else class="flex w-full">
<Icon name="svg-spinners:3-dots-fade" class="text-6" />
</div>
</div>
</NuxtLink>
</div>
</div>
</nav>
+7 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
const { agents } = await useAgents();
const { agents, unsubscribe: unsubscribeAgents, createAgent } = await useAgents();
const agentsListRef = ref<HTMLElement | null>(null);
const agentsOpen = ref(true);
const agentsListHeight = ref('auto');
@@ -62,30 +62,15 @@ const newAgent = async () => {
if (!user.value) throw new Error('User not logged in');
const agent = await triplit.insert('agents', {
name: 'New Agent',
userId: user.value.id,
systemPrompt: 'You are a helpful assistant.',
imageUrl: null,
createdAt: new Date(),
});
console.log(agents.value, agent);
const agent = await createAgent();
if (!agent) throw new Error('Failed to create agent');
let agentExists: () => void;
const agentExistsPromise = new Promise<void>((resolve) => {
agentExists = resolve;
});
watch(agents, () => {
agentExists();
});
await agentExistsPromise;
navigateTo(`/agent/${agent.id}`);
return navigateTo(`/agent/${agent.id}`);
};
onUnmounted(() => {
unsubscribeAgents?.();
});
</script>
<template>
+75 -29
View File
@@ -6,8 +6,34 @@ const isResizing = ref(false);
const startX = ref(0);
const initialWidth = ref(0);
const isMouseOver = ref(false);
const isFocused = ref(false);
const lastInteraction = ref<'mouse' | 'keyboard' | null>(null);
const isHovering = computed(() =>
isMouseOver.value || (isFocused.value && lastInteraction.value === 'keyboard')
);
const sidenavRef = ref<HTMLElement | null>(null);
const closeSidenavRef = ref<HTMLElement | null>(null);
provideSidenavContext({
isHovered: isHovering,
sidebarWidth: sidebarWidth,
isOpen: open,
close: () => {
closeSidebar();
},
});
const trackInteraction = (interaction: 'mouse' | 'keyboard') => {
lastInteraction.value = interaction;
};
const onFocusOut = (e: FocusEvent) => {
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
if (isMovingOutside) {
isFocused.value = false;
}
};
const { toggle: toggleSettings } = useSettings();
@@ -46,63 +72,55 @@ onMounted(() => {
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('mouseup', onResizeEnd);
watch(hovering, (value) => {
if (!closeSidenavRef.value) return;
if (value) {
const width = closeSidenavRef.value.scrollWidth;
closeSidenavRef.value.style.width = `${width}px`;
} else {
closeSidenavRef.value.style.width = '0';
}
// NEW: Track global interactions
document.addEventListener('mousedown', () => trackInteraction('mouse'));
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
});
onUnmounted(() => {
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
});
const hovering = ref(false);
// NEW: Cleanup listeners
document.removeEventListener('mousedown', () => trackInteraction('mouse'));
document.removeEventListener('keydown', (e) => {
if (e.key === 'Tab') trackInteraction('keyboard');
});
});
const navKind = computed(() => {
if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent';
return null;
});
const onFocusOut = (e: FocusEvent) => {
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
if (isMovingOutside) {
hovering.value = false;
}
};
</script>
<template>
<div class="relative">
<aside ref="sidenavRef" :class="[
'h-full max-w-fit bg-[var(--color-base)] overflow-hidden will-change-width text-[var(--color-muted)] select-none',
open ? 'w-full mr-2' : 'w-0 mr-0',
isResizing ? '' : 'transition-[width,margin] duration-250 ease-[cubic-bezier(0,0.55,0.45,1)]'
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="hovering = true"
@mouseleave="hovering = false" @focusin="hovering = true" @focusout="onFocusOut">
'sidenav',
open ? 'sidenav--open' : 'sidenav--closed',
isResizing ? 'sidenav--resizing' : ''
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center mb-1.5">
<SidenavHeader v-if="navKind === 'home'" v-model="hovering" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" v-model="hovering" />
<SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
<div class="flex items-center justify-end text-[var(--color-muted)] gap-0.5">
<div ref="closeSidenavRef" style="width: 0;"
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] scale-100 bg-transparent transition-inherit',
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-highlight)] focus-visible:bg-[var(--color-highlight)] bg-transparent transition-inherit',
]">
<Icon name="mynaui:panel-left-close"
:class="['transition-inherit', hovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
:class="['transition-inherit transform-origin-right-center', isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95']" />
</button>
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
@@ -143,3 +161,31 @@ const onFocusOut = (e: FocusEvent) => {
</div>
</div>
</template>
<style scoped>
.sidenav {
height: 100%;
max-width: fit-content;
background: var(--color-base);
overflow: hidden;
will-change: width;
color: var(--color-muted);
user-select: none;
transition: width 250ms cubic-bezier(0, 0.55, 0.45, 1),
margin 250ms cubic-bezier(0, 0.55, 0.45, 1);
}
.sidenav--open {
width: 100%;
margin-right: 0.5rem;
}
.sidenav--closed {
width: 0;
margin-right: 0;
}
.sidenav--resizing {
transition: none;
}
</style>