continue scaffolding and refine the basic foundation

This commit is contained in:
Zoe
2026-01-13 19:56:24 +00:00
parent 0877cc10bd
commit 8c28946703
37 changed files with 1431 additions and 509 deletions
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown';
interface Props {
items: DropdownItem[];
modelValue?: boolean;
placement?: 'right' | 'left' | 'center';
verticality?: 'asscending' | 'descending';
width?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
placement: 'right',
verticality: 'descending',
width: 'auto'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'select', item: DropdownItem): void;
}>()
const triggerRef = ref<HTMLElement | null>(null)
const isOpen = defineModel<boolean>({ required: true })
const toggle = () => {
isOpen.value = !isOpen.value
}
const select = (item: DropdownItem) => {
if (item.disabled || item.divider) return
emit('select', item)
isOpen.value = false
}
const placementClasses = computed(() => {
let classes = ''
switch (props.placement) {
case 'right':
classes += 'right-0 '
break
case 'left':
classes += 'left-0 '
break
case 'center':
classes += 'left-1/2 -translate-x-1/2 '
break
default:
classes += 'left-0 '
break
}
switch (props.verticality) {
case 'asscending':
classes += 'bottom-full mb-1.5'
break
case 'descending':
classes += 'top-full mt-1.5'
break
}
return classes
})
useClickOutside(triggerRef, () => {
isOpen.value = false
})
</script>
<template>
<div ref="triggerRef" :class="$attrs.class">
<slot name="trigger" :toggle="toggle" :is-open="isOpen"></slot>
<Transition enter-active-class="transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isOpen" :class="[
'absolute z-50 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-1.5 flex flex-col gap-1',
placementClasses
]" :style="{ width: width !== 'auto' ? width : undefined }">
<template v-for="(item, index) in items" :key="index">
<div v-if="item.divider" class="h-px bg-[var(--color-highlight)] my-1" />
<button @click="select(item); item.onClick && item.onClick()" :disabled="item.disabled"
class="bg-transparent w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left"
:class="[
item.disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-[var(--color-highlight)] cursor-pointer'
]">
<Icon v-if="item.icon" :name="item.icon" class="w-4 h-4 flex-shrink-0" />
<span class="text-sm whitespace-nowrap">{{ item.label }}</span>
<slot name="item-after" :item="item"></slot>
</button>
</template>
<slot name="content"></slot>
</div>
</Transition>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { Message } from '~~/types';
interface Props {
message: Message;
regenerations?: Message[];
isCurrent?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
regenerations: () => [],
isCurrent: true
})
const emit = defineEmits<{
regenerate: [messageId: string];
select: [messageId: string];
delete: [messageId: string];
}>()
const isOpen = ref(false)
const content = computed(() => props.message.content)
const hasAlternatives = computed(() => props.regenerations.length > 0)
const showDropdown = computed(() => hasAlternatives.value || !props.isUser)
</script>
<template>
<div class="flex gap-3 relative group">
<div
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
:class="isUser ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-neutral)] border border-[var(--color-highlight)]'"
>
<Icon
:name="isUser ? 'mynaui:user' : 'mynaui:check-hexagon'"
class="w-4 h-4"
:class="isUser ? 'text-[var(--color-accent-text)]' : 'text-[var(--color-accent)]'"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="text-sm font-medium" :class="isUser ? 'text-[var(--color-accent)]' : 'text-[var(--color-neutral)]'">
{{ isUser ? 'You' : 'Agent' }}
</p>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Dropdown v-if="showDropdown" v-model="isOpen" placement="bottom-right" width="140px">
<template #trigger="{ toggle }">
<button
@click="toggle"
class="p-1 rounded hover:bg-[var(--color-highlight)]"
aria-label="More options"
>
<Icon name="mynaui:dots-horizontal" class="w-4 h-4 text-[var(--color-subtle)]" />
</button>
</template>
<template #default>
<button
v-if="!isUser"
@click="emit('regenerate', message.id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
>
<Icon name="mynaui:refresh" class="w-4 h-4" />
<span class="text-sm">Regenerate</span>
</button>
<div v-if="!isUser && hasAlternatives" class="h-px bg-[var(--color-highlight)] my-1" />
<template v-if="hasAlternatives">
<button
v-for="alt in regenerations"
:key="alt.id"
@click="emit('select', alt.id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left"
:class="message.id === alt.id ? 'bg-[var(--color-highlight)]' : ''"
>
<Icon name="mynaui:clock" class="w-4 h-4 text-[var(--color-subtle)]" />
<span class="text-sm text-[var(--color-subtle)]">{{ alt.id }}</span>
</button>
</template>
<div class="h-px bg-[var(--color-highlight)] my-1" />
<button
@click="emit('delete', message.id)"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--color-highlight)] text-left text-red-400"
>
<Icon name="mynaui:trash" class="w-4 h-4" />
<span class="text-sm">Delete</span>
</button>
</template>
</Dropdown>
</div>
</div>
<p class="text-sm text-[var(--color-text)] whitespace-pre-wrap break-words">{{ content }}</p>
<div v-if="editedAt" class="mt-1 text-xs text-[var(--color-subtle)] flex items-center gap-1">
<Icon name="mynaui:pencil" class="w-3 h-3" />
<span>Edited</span>
</div>
</div>
</div>
</template>
+66 -54
View File
@@ -13,67 +13,79 @@ const simulateTask = () => {
</script>
<template>
<div v-if="open" class="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80"
@click.self="close">
<div class="w-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]" enter-from-class="opacity-0"
enter-to-class="opacity-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="open" class="fixed inset-0 z-45 bg-black/80" @click.self="close">
</div>
</Transition>
<Transition class="transition-all duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-2" leave-from-class="opacity-100 scale-100 translate-y-0"
enter-to-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 -translate-y-2">
<div v-if="open" class="z-50 fixed top-1/2 left-1/2 -translate-x-1/2 flex items-center justify-center">
<div class="absolute w-[70vw] max-w-6xl h-[70vh] bg-[var(--color-base)] rounded-xl shadow-2xl border border-[var(--color-highlight)]
overflow-hidden flex max-h-[90vh] p-2">
<!-- Sidebar Nav -->
<nav class="w-64 flex flex-col gap-2">
<div class="flex items-center justify-between pb-4">
<div class="flex items-center gap-2 px-2">
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
<h1 class="font-semibold text-center">Settings</h1>
<!-- Sidebar Nav -->
<nav class="w-64 flex flex-col gap-2">
<div class="flex items-center justify-between pb-4">
<div class="flex items-center gap-2 px-2">
<Icon name="mynaui:cog-four" class="w-7 h-7 text-[var(--color-subtle)] mt-1" />
<h1 class="font-semibold text-center">Settings</h1>
</div>
</div>
</div>
<div v-for="page in pages" :key="page" :class="[
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
]" @click="setPage(page)">
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
</div>
</nav>
<!-- Content -->
<main class="flex-1 flex flex-col overflow-hidden">
<header class="flex items-center justify-between pl-2 pb-2">
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1) }}
</h2>
<div class="flex items-center gap-3">
<div id="settings-loader-target"></div>
<button @click="close"
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
<Icon name="mynaui:x-solid" class="w-5 h-5" />
</button>
<div v-for="page in pages" :key="page" :class="[
'select-none cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors w-full text-left',
currentPage === page ? 'bg-[var(--color-highlight)]' : 'hover:bg-[var(--color-highlight)]/10'
]" @click="setPage(page)">
{{ page.charAt(0).toUpperCase() + page.slice(1) }}
</div>
</header>
</nav>
<div
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
<div v-if="currentPage === 'page1'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a task to
the queue
below.
</p>
<div class="flex gap-2">
<button class="accent" @click="simulateTask">
Simulate Task (2s)
<!-- Content -->
<main class="flex-1 flex flex-col overflow-hidden">
<header class="flex items-center justify-between pl-2 pb-2">
<h2 class="text-lg font-semibold">{{ currentPage.charAt(0).toUpperCase() + currentPage.slice(1)
}}
</h2>
<div class="flex items-center gap-3">
<div id="settings-loader-target"></div>
<button @click="close"
class="p-1.5 rounded-lg text-[var(--color-text)] bg-transparent hover:bg-[var(--color-highlight)]/10 transition-colors">
<Icon name="mynaui:x-solid" class="w-5 h-5" />
</button>
</div>
</div>
<div v-else-if="currentPage === 'page2'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some details.
</p>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
</header>
<div
class="flex-1 p-6 ml-1 mt-1 bg-[var(--color-neutral)] overflow-y-auto border rounded-lg border-[var(--color-highlight)]">
<div v-if="currentPage === 'page1'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 1 content. Try adding a
task to
the queue
below.
</p>
<div class="flex gap-2">
<button class="accent" @click="simulateTask">
Simulate Task (2s)
</button>
</div>
</div>
<div v-else-if="currentPage === 'page2'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 2 content with some
details.
</p>
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 1</div>
<div class="p-4 bg-[var(--color-highlight-low)] rounded-lg text-sm">Item 2</div>
</div>
</div>
<div v-else-if="currentPage === 'page3'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
<button class="accent" @click="simulateTask">Run Task</button>
</div>
</div>
<div v-else-if="currentPage === 'page3'">
<p class="text-sm text-[var(--color-subtle)] mb-4">Settings page 3 content.</p>
<button class="accent" @click="simulateTask">Run Task</button>
</div>
</div>
</main>
</main>
</div>
</div>
</div>
</Transition>
</template>
+29 -34
View File
@@ -1,51 +1,46 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
const { user, signOut } = useAuth()
const { toggle: toggleSettings } = useSettings()
const profileRef = ref<HTMLElement | null>(null)
const profileOpen = ref(false)
const hovering = defineModel<boolean>({ required: true })
const toggleProfile = () => {
profileOpen.value = !profileOpen.value
}
const profileOpen = ref(false)
const handleLogout = async () => {
await signOut()
profileOpen.value = false
}
useClickOutside(profileRef, () => {
profileOpen.value = false
})
const profileItems: DropdownItem[] = [
{ label: 'Settings', icon: 'mynaui:cog-four', onClick: toggleSettings },
{ label: 'Log out', icon: 'mynaui:logout', divider: true, onClick: handleLogout },
]
</script>
<template>
<header class="flex items-center justify-between overflow-hidden">
<div role="button" aria-label="open user dropdown" ref="profileRef"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
@click="toggleProfile">
<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" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
user!.name
}}</span>
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] 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'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
</div>
<!-- Profile Dropdown -->
<div v-show="profileOpen"
class="absolute top-full left-0 right-0 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-xl p-2 w-full gap-1 flex flex-col text-[var(--color-text)]">
<SidenavItem @click="toggleSettings(); profileOpen = false" name="Settings" icon="mynaui:cog-four" />
<SidenavItem @click="handleLogout" name="Log out" icon="mynaui:logout" />
</div>
<Dropdown v-model="profileOpen" :items="profileItems" placement="left" verticality="descending" width="100%">
<template #trigger="{ toggle }">
<div role="button" aria-label="open user dropdown"
class="flex items-center gap-1.5 pr-2 rounded-xl hover:bg-[var(--color-highlight)] cursor-pointer transition-colors max-w-full"
@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" />
<Icon v-else name="mynaui:user" class="w-4 h-4 text-[var(--color-subtle)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">{{
user!.name
}}</span>
<div :class="['flex-shrink-0 w-4 h-4 text-[var(--color-subtle)] 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'
]">
<Icon class="text-4" name="mynaui:chevron-down" />
</div>
</div>
</template>
</Dropdown>
</header>
</template>
+37 -34
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
const { user } = useAuth()
const { agents, activeAgent } = await useAgents()
const homeButtonRef = ref<HTMLElement | null>(null)
const agentDropdownRef = ref<HTMLElement | null>(null)
const agentDropdownOpen = ref(false)
const hovering = defineModel<boolean>({ required: true })
const initialized = ref(false)
@@ -26,11 +26,11 @@ onMounted(() => {
homeButtonRef.value!.style.width = '0'
}
})
});
useClickOutside(agentDropdownRef, () => {
agentDropdownOpen.value = false
})
const agentDropdownOpen = ref(false)
const agentItems: DropdownItem[] = []
</script>
<template>
@@ -43,33 +43,36 @@ useClickOutside(agentDropdownRef, () => {
</NuxtLink>
</div>
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
ref="agentDropdownRef" @click="agentDropdownOpen = !agentDropdownOpen">
<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="activeAgent?.imageUrl" :src="activeAgent.imageUrl" class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--color-subtle)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</div>
<!-- Agent Dropdown -->
<div v-show="agentDropdownOpen" class="absolute top-full left-1/10 z-50 mt-1.5 bg-[var(--color-neutral)] border border-[var(--color-highlight)]
rounded-xl p-2 w-8/10">
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
:active="activeAgent?.id === agent.id" />
</NuxtLink>
</div>
</div>
<Dropdown v-model="agentDropdownOpen" :items="agentItems" placement="center" width="calc(80% - 1rem)">
<template #trigger="{ toggle }">
<div class="flex overflow-hidden gap-1.5 pr-2 items-center cursor-pointer hover:bg-[var(--color-highlight)] rounded-lg"
@click="toggle">
<div
:class="['w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--color-neutral)] flex items-center justify-center', activeAgent?.imageUrl ? '' : 'border border-[var(--color-highlight-high)]']">
<img v-if="activeAgent?.imageUrl" :src="activeAgent.imageUrl"
class="w-full h-full object-cover" />
<Icon v-else name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<span
class="text-sm font-medium text-ellipsis overflow-hidden text-[var(--color-text)] whitespace-nowrap">
{{ activeAgent?.name }}
</span>
<div class="w-4 h-4 text-[var(--color-subtle)]">
<Icon
class="text-4 transform-origin-center-left duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transition-all"
name="mynaui:chevron-up-down" />
</div>
</div>
</template>
<template #content>
<div class="flex flex-col gap-1.5 max-h-[calc(2.25rem*4+0.375rem*3)] overflow-y-auto">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id" :class="['decoration-none whitespace-nowrap', activeAgent?.id === agent.id ? 'text-[var(--color-text)]' :
'text-[var(--color-subtle)]']" @click="agentDropdownOpen = false">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon"
:active="activeAgent?.id === agent.id" />
</NuxtLink>
</div>
</template>
</Dropdown>
</header>
</template>
+15 -11
View File
@@ -1,32 +1,36 @@
<script setup lang="ts">
const route = useRoute()
const route = useRoute();
const routeParts = computed(() => {
return route.path.replace('/agent/', '').split('/')
return route.path.replace('/agent/', '').split('/');
});
if (routeParts.value.length < 1) navigateTo('/')
if (!routeParts.value[0]!.match(/^agents_[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/)) navigateTo('/')
if (routeParts.value.length < 1) navigateTo('/');
const pageInfo = computed(() => {
// `/agent/agent_[uuid]`
if (routeParts.value.length === 1) {
return 'new-conversation'
// `/agent/agent_[uuid]` or `/agent/agent_[uuid]/topic/...`
if (route.path.startsWith('/agent/') && !route.path.includes('/profile')) {
return 'conversation';
}
// `/agent/agent_[uuid]/profile`
if (routeParts.value.length === 2 && routeParts.value[1]! === 'profile') {
return 'agent-profile'
if (route.path.endsWith('/profile')) {
return 'agent-profile';
}
})
});
</script>
<template>
<nav class="flex flex-col gap-1">
<!-- Agent Info Link -->
<div class="mt-2">
<NuxtLink :to="`/agent/${routeParts[0]}/profile`" class="decoration-none text-[var(--color-subtle)]">
<SidenavItem name="Agent Info" icon="mynaui:info-square" :active="pageInfo === 'agent-profile'" />
</NuxtLink>
</div>
<!-- Topics Section -->
<SidenavNavAgentTopics />
</nav>
</template>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
const route = useRoute();
const appState = useAppState();
const topicsListRef = ref<HTMLElement | null>(null);
const topicsListHeight = ref('auto');
const topicsListOpacity = ref(1);
const topicsListScale = ref(1);
const { topicsForActiveAgent, createTopic } = await useTopics();
const { activeAgent } = await useAgents();
const creatingTopic = ref(false);
const topicsOpen = ref(true);
function easeInOutQuad(x: number): number {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
const toggleAgentsList = () => {
if (!topicsListRef.value) return;
let animationLength = 200;
let animationStart: number | null = null;
let startHeight: number;
let startOpacity = topicsListOpacity.value;
let startScale = topicsListScale.value;
if (topicsListHeight.value === 'auto') {
startHeight = topicsListRef.value.clientHeight;
} else {
startHeight = Number(topicsListHeight.value.replace('px', ''));
}
let targetHeight = topicsOpen.value ? 0 : topicsListRef.value.scrollHeight;
let targetOpacity = topicsOpen.value ? 0 : 1;
let targetScale = topicsOpen.value ? 0.95 : 1;
topicsOpen.value = !topicsOpen.value;
const animate = (timestamp: number) => {
if (!animationStart) animationStart = timestamp;
const elapsed = timestamp - animationStart;
const progress = Math.min(elapsed / animationLength, 1);
const currentHeight = startHeight + (targetHeight - startHeight) * easeInOutQuad(progress);
const currentOpacity = startOpacity + (targetOpacity - startOpacity) * easeInOutQuad(progress);
const currentScale = startScale + (targetScale - startScale) * easeInOutQuad(progress);
topicsListOpacity.value = currentOpacity;
topicsListScale.value = currentScale;
topicsListHeight.value = `${currentHeight}px`;
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (topicsOpen.value) {
topicsListHeight.value = 'auto';
}
}
};
requestAnimationFrame(animate);
}
const newTopic = async () => {
if (!activeAgent.value) return;
creatingTopic.value = true;
try {
const newTopic = await createTopic('New Topic', activeAgent.value.id);
// Navigate to new topic
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`);
} catch (error) {
console.error('Failed to create topic:', error);
} finally {
creatingTopic.value = false;
}
};
</script>
<template>
<div class="flex flex-col gap-2">
<!-- Header -->
<button @click="toggleAgentsList"
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left">
<span class="text-sm font-medium">Topics</span>
<Icon name="mynaui:chevron-down" :class="['w-4 h-4', topicsOpen ? '' : '-rotate-90']" />
</button>
<div ref="topicsListRef"
:style="{ height: topicsListHeight, opacity: topicsListOpacity, transform: `scale(${topicsListScale})` }"
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
<button @click="newTopic" :disabled="creatingTopic"
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
<div class="h-7 w-7 flex items-center justify-center">
<Icon v-if="creatingTopic" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" />
</div>
<span>{{ creatingTopic ? 'Creating...' : 'New Topic' }}</span>
</button>
<NuxtLink v-for="topic in topicsForActiveAgent" :to="`/agent/${activeAgent?.id}/topic/${topic.id}`"
class="decoration-none text-[var(--color-subtle)]">
<SidenavItem :name="topic.name" icon="mynaui:check-hexagon" />
</NuxtLink>
</div>
</div>
</template>
+22 -24
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
const { agents, createAgent } = await useAgents()
const route = useRoute()
const agentsListRef = ref<HTMLElement | null>(null)
const agentsOpen = ref(true)
const agentsListHeight = ref('auto')
@@ -73,30 +72,29 @@ const newAgent = async () => {
</NuxtLink>
<!-- Agents Section -->
<div class="relative group">
<div class="flex items-center justify-between px-2 h-9 rounded-lg hover:bg-[var(--color-highlight)] transition-colors cursor-pointer"
@click="toggleAgentsList()">
<div class="flex items-center gap-0.5">
<span class="text-sm">Agents</span>
<Icon name="mynaui:chevron-right-solid"
:class="['transition-transform duration-200 ease-in-out transform-origin-center', agentsOpen ? 'rotate-90' : '']" />
</div>
<button aria-label="create new agent"
class="opacity-0 group-hover:opacity-100 p-1 rounded-md transition-all bg-transparent hover:bg-[var(--color-highlight)] text-[var(--color-subtle)] active:text-[var(--color-text)]"
@click.stop="newAgent">
<Icon v-if="!creatingAgent" name="mynaui:plus" class="w-4 h-4" />
<Icon v-else name="svg-spinners:ring-resize" class="w-4 h-4" />
</button>
</div>
<div class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent hover:bg-[var(--color-highlight)] transition-colors w-full text-left"
@click="toggleAgentsList()">
<span class="text-sm">Agents</span>
<Icon name="mynaui:chevron-down"
:class="['w-4 h-4 transition-transform duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center', agentsOpen ? '' : '-rotate-90']" />
</div>
<div ref="agentsListRef"
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
class="decoration-none text-[var(--color-subtle)]">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
</NuxtLink>
</div>
<div ref="agentsListRef"
:style="{ height: agentsListHeight, opacity: agentsListOpacity, transform: `scale(${agentsListScale})` }"
class="mt-1 gap-1 flex flex-col overflow-hidden transform-origin-center-top">
<button @click="newAgent" :disabled="creatingAgent"
class="flex items-center gap-2 px-1 h-9 shrink-0 rounded-lg text-sm text-[var(--color-subtle)] bg-transparent hover:bg-[var(--color-highlight)] transition-colors disabled:opacity-50 w-full">
<div class="h-7 w-7 flex items-center justify-center">
<Icon v-if="creatingAgent" class="text-4.5" name="svg-spinners:ring-resize" />
<Icon v-else class="text-4.5" name="mynaui:plus" />
</div>
<span>New Agent</span>
</button>
<NuxtLink v-for="agent in agents" :to="`/agent/${agent.id}`" :key="agent.id"
class="decoration-none text-[var(--color-subtle)]">
<SidenavItem :name="agent.name" icon="mynaui:check-hexagon" />
</NuxtLink>
</div>
</nav>
</template>
+2 -1
View File
@@ -78,7 +78,8 @@ const navKind = computed(() => {
<div :style="{ minWidth: `${sidebarWidth}px` }" class="flex flex-col h-full justify-between">
<div class="flex flex-col">
<!-- Header -->
<div class="relative flex flex-row gap-2 justify-between items-center pb-1.5">
<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" />
+16 -31
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { DropdownItem } from '~/types/dropdown'
type Theme = 'light' | 'dark' | 'system'
const colorMode = useColorMode()
const isOpen = ref(false)
const buttonRef = ref<HTMLElement | null>(null)
const themeOptions: { value: Theme; label: string; icon: string }[] = [
const themeOptions: DropdownItem[] = [
{ value: 'light', label: 'Light', icon: 'mynaui:sun' },
{ value: 'dark', label: 'Dark', icon: 'mynaui:moon' },
{ value: 'system', label: 'System', icon: 'mynaui:desktop' }
@@ -15,38 +15,23 @@ const currentOption = computed(() =>
themeOptions.find(option => option.value === colorMode.preference) || themeOptions[2]
)
const selectTheme = (newTheme: Theme) => {
colorMode.preference = newTheme
isOpen.value = false
}
const isOpen = ref(false)
const toggleDropdown = () => {
isOpen.value = !isOpen.value
const selectTheme = (item: DropdownItem) => {
colorMode.preference = item.value as Theme
}
useClickOutside(buttonRef, () => {
isOpen.value = false
})
</script>
<template>
<div ref="buttonRef" class="relative">
<button aria-label="Open theme switcher" @click="toggleDropdown"
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
<Icon :name="currentOption!.icon"
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
</button>
<div v-if="isOpen"
class="flex flex-col gap-1 absolute bottom-full mb-2 right-0 bg-[var(--color-neutral)] border border-[var(--color-highlight)] rounded-lg p-1 min-w-[120px] shadow-lg">
<button v-for="option in themeOptions" :key="option.value" @click="selectTheme(option.value)"
class="w-full flex items-center justify-left gap-2 px-3 py-2 rounded-md hover:bg-[var(--color-highlight)] transition-colors"
:class="colorMode.preference === option.value ? 'bg-[var(--color-highlight)] text-[var(--color-text)]' :
'bg-transparent text-[var(--color-subtle)]'">
<Icon :name="option.icon" class="w-4 h-4" />
<span class="text-sm">{{ option.label }}</span>
<Dropdown class="relative" v-model="isOpen" @select="selectTheme" :items="themeOptions" verticality="asscending"
placement="right" width="140px">
<template #trigger="{ toggle, isOpen }">
<button aria-label="Open theme switcher" @click="toggle"
class="p-2 flex items-center justify-center rounded-lg hover:bg-[var(--color-highlight)] transition-colors group"
:class="isOpen ? 'bg-[var(--color-highlight)]' : 'bg-transparent'">
<Icon :name="currentOption!.icon!"
class="text-5 text-[var(--color-subtle)] group-hover:text-[var(--color-text)] transition-colors" />
</button>
</div>
</div>
</template>
</Dropdown>
</template>
+4 -6
View File
@@ -3,18 +3,16 @@ import type { Agent } from '~~/types'
export const useAgents = async () => {
const { addTask, completeTask } = useTasks()
const appState = useAppState()
const fetchingAgents = ref(false);
const agents: Ref<Agent[] | null> = useState('agents', () => null);
const activeAgent = computed(() => {
if (agents.value === null) return;
if (!appState.activeAgentId.value) return;
const routeId = useRoute().params.id;
if (routeId === undefined) return;
const agent = agents.value.find(agent => agent.id === routeId);
if (agent === undefined) return;
const agent = agents.value.find(agent => agent.id === appState.activeAgentId.value);
return agent;
});
+90
View File
@@ -0,0 +1,90 @@
import type { User, Session } from 'better-auth/types';
/**
* Central application state composable
* Manages navigation context and critical app-level state
* This is the single source of truth for "what am I viewing"
*/
export const useAppState = () => {
// Current navigation context
const activeAgentId = useState<string | null>('appState:activeAgentId', () => null);
const activeTopicId = useState<string | null>('appState:activeTopicId', () => null);
// User data
const user = useState<User | null>('appState:user', () => null);
const session = useState<Session | null>('appState:session', () => null);
// Loading states
const isInitializing = useState<boolean>('appState:isInitializing', () => true);
const generationInProgress = useState<{ generationId: string } | null>('appState:generationInProgress', () => null);
/**
* Set the active agent and clear the topic
*/
const setActiveAgent = (agentId: string | null | undefined) => {
activeAgentId.value = agentId || null;
// Clear topic when switching agents
activeTopicId.value = null;
};
/**
* Set the active topic
*/
const setActiveTopic = (topicId: string | null | undefined) => {
activeTopicId.value = topicId || null;
};
/**
* Set user session data
*/
const setUser = (userData: User | null) => {
user.value = userData;
};
/**
* Set session
*/
const setSession = (sessionData: Session | null) => {
session.value = sessionData;
};
/**
* Mark initialization complete
*/
const markInitialized = () => {
isInitializing.value = false;
};
/**
* Start a generation
*/
const startGeneration = (generationId: string) => {
generationInProgress.value = { generationId };
};
/**
* End current generation
*/
const endGeneration = () => {
generationInProgress.value = null;
};
return {
// State
activeAgentId,
activeTopicId,
user,
session,
isInitializing,
generationInProgress,
// Actions
setActiveAgent,
setActiveTopic,
setUser,
setSession,
markInitialized,
startGeneration,
endGeneration
};
};
+26 -16
View File
@@ -1,17 +1,23 @@
import type { Topic } from "~~/types";
export const useTopics = async () => {
const appState = useAppState()
const fetchingTopics = ref(false);
const topics: Ref<Topic[] | null> = useState('topics', () => null);
const topics: Ref<any[] | null> = useState('topics', () => null);
/**
* Compute topics for the currently active agent
*/
const topicsForActiveAgent = computed(() => {
if (topics.value === null || !appState.activeAgentId.value) return [];
return topics.value.filter(topic => topic.agentId === appState.activeAgentId.value);
});
const activeTopic = computed(() => {
if (topics.value === null) return;
const routeId = useRoute().query.topicId;
if (routeId === undefined) return;
const topic = topics.value.find(topic => topic.id === routeId);
if (topic === undefined) return;
if (topicsForActiveAgent.value.length === 0) return;
if (!appState.activeTopicId.value) return;
const topic = topicsForActiveAgent.value.find(topic => topic.id === appState.activeTopicId.value);
return topic;
});
@@ -19,16 +25,17 @@ export const useTopics = async () => {
if (fetchingTopics.value) return;
fetchingTopics.value = true;
const { data, error } = await useFetch('/api/topics');
if (error.value) throw error;
topics.value = data.value!;
fetchingTopics.value = false;
try {
const { data, error } = await useFetch('/api/topics');
if (error.value) throw error;
topics.value = data.value!;
} finally {
fetchingTopics.value = false;
}
}
if (topics.value === null) await refreshTopics();
const createTopic = async (name: string, agentId: string) => {
const res = await fetch('/api/topics', {
method: 'POST',
@@ -42,8 +49,11 @@ export const useTopics = async () => {
throw new Error('Failed to create topic')
}
return res.json()
const newTopic = await res.json()
if (topics.value === null) topics.value = []
topics.value.push(newTopic)
return newTopic
}
return { createTopic, activeTopic, topics }
return { createTopic, activeTopic, topics, topicsForActiveAgent, fetchingTopics, refreshTopics }
}
+20 -76
View File
@@ -1,77 +1,24 @@
<script setup lang="ts">
import type { Message } from '~~/types'
const { createTopic, fetchingTopics } = await useTopics();
const { activeAgent } = await useAgents();
const { createTopic, activeTopic } = await useTopics()
const { activeAgent } = await useAgents()
const loading = ref(false)
const messages = useState<Message[]>('messages', () => [])
const generatingMessage = ref('')
if (activeTopic.value !== undefined) {
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`)
if (topicData.error.value) throw topicData.error
messages.value = topicData.data.value!.messages
console.log(messages.value)
}
const creatingTopic = ref(false);
const handleSubmit = async (message: string) => {
loading.value = true
let topic;
if (activeTopic.value) {
topic = activeTopic.value
} else {
topic = await createTopic('New Topic', activeAgent.value!.id)
// create a new topic, and send the message to it
console.log('handleSubmit', message);
if (!activeAgent.value) return;
creatingTopic.value = true
try {
const newTopic = await createTopic('New Topic', activeAgent.value.id)
// Navigate to the new topic
await navigateTo(`/agent/${activeAgent.value.id}/topic/${newTopic.id}`)
} catch (error) {
console.error('Failed to create topic:', error)
} finally {
creatingTopic.value = false
}
console.log(topic.id)
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: topic.id,
messages: [{
type: 'user',
message
}]
})
})
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
})
// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
const reader = response.pipeThrough(new TextDecoderStream()).getReader()
generatingMessage.value = ''
// Read the data from the stream and update the UI
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const { type, data } = JSON.parse(value)
if (type === 'token') {
generatingMessage.value += data
continue
}
if (type === 'complete') {
if (generatingMessage.value !== data) {
generatingMessage.value = data
}
break
}
}
loading.value = false
}
</script>
@@ -79,15 +26,12 @@ const handleSubmit = async (message: string) => {
<div class="flex h-full w-full pb-4 justify-center">
<div class="max-w-4xl h-full w-full flex flex-col">
<!-- chat pane -->
<div class="flex h-full flex-col gap-6">
<p v-if="activeTopic !== undefined" v-for="message in messages" :key="message.id">
{{ message.content }}
</p>
<p v-else>No messages yet</p>
<p v-if="generatingMessage" class="text-sm text-gray-500">{{ generatingMessage }}</p>
<div class="flex h-full flex-col gap-2 justify-end mb-28">
<h1 v-if="activeAgent" class="font-bold">{{ activeAgent.name }}</h1>
<p class="text-[var(--color-subtle)]">Select a topic to continue or create a new one</p>
</div>
<ChatInput @submit="handleSubmit" :loading="loading" />
<ChatInput @submit="handleSubmit" :loading="fetchingTopics" />
</div>
</div>
</template>
+1 -1
View File
@@ -2,7 +2,7 @@
const { activeAgent: agent, updateAgent } = await useAgents();
const route = useRoute()
if (agent.value === undefined) navigateTo('/');
// if (agent.value === undefined) navigateTo('/');
const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement;
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import type { Message } from '~~/types';
const route = useRoute();
const appState = useAppState();
const { activeTopic, topicsForActiveAgent } = await useTopics();
const { activeAgent } = await useAgents();
const loading = ref(false);
const messages = useState<Message[]>('messages', () => []);
const generatingMessage = ref('');
// Fetch messages for this topic
if (activeTopic.value) {
const topicData = await useFetch(`/api/topics/${activeTopic.value.id}`);
if (topicData.error.value) {
console.error('Failed to load topic:', topicData.error.value);
} else if (topicData.data.value?.messages) {
messages.value = topicData.data.value.messages;
}
}
const handleSubmit = async (message: string) => {
if (!activeTopic.value) {
console.error('No active topic');
return;
}
loading.value = true;
try {
// Add user message to local state
const userMessage: Message = {
id: `temp_${Date.now()}`,
topicId: activeTopic.value.id,
userId: '',
content: message,
isUser: true,
regeneratedFromId: null,
isRegenerated: false,
editedAt: null,
createdAt: new Date() as any
};
messages.value.push(userMessage);
// Create generation
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: activeTopic.value.id,
messages: messages.value
.filter(m => m.content)
.map(m => ({
type: m.isUser ? 'user' : 'agent',
message: m.content
}))
})
});
appState.startGeneration(generation.generationId);
// Stream the response
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
});
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
generatingMessage.value = '';
let hasError = false;
while (!hasError) {
const { done, value } = await reader.read();
if (done) break;
try {
const lines = value.split('\n').filter(line => line.trim());
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === 'start') {
generatingMessage.value = '';
} else if (event.type === 'token') {
generatingMessage.value += event.data;
} else if (event.type === 'complete') {
// Add the completed message to the list
console.log(event, event.data);
if (event.data) {
messages.value.concat(event.data);
generatingMessage.value = '';
}
} else if (event.type === 'error') {
console.error('Generation error:', event.data);
hasError = true;
}
}
} catch (parseError) {
console.error('Failed to parse event:', parseError);
}
}
appState.endGeneration();
} catch (error) {
console.error('Failed to submit message:', error);
} finally {
loading.value = false;
}
};
const handleRegenerate = async (messageId: string) => {
if (!activeTopic.value) return;
// Find the user message before this one to regenerate context
const messageIndex = messages.value.findIndex(m => m.id === messageId);
if (messageIndex === -1) return;
const previousUserMessage = messages.value[messageIndex - 1];
if (!previousUserMessage) return;
loading.value = true;
try {
// Create generation with previous user message
const generation = await $fetch(`/api/chat/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
topicId: activeTopic.value.id,
regeneratesFrom: messageId,
messages: messages.value.slice(0, messageIndex)
.map(m => ({
type: m.isUser ? 'user' : 'agent',
message: m.content
}))
})
});
appState.startGeneration(generation.generationId);
// Stream the response
const response = await $fetch<ReadableStream>(`/api/chat/stream/${generation.generationId}`, {
method: 'get',
responseType: 'stream',
});
const reader = response.pipeThrough(new TextDecoderStream()).getReader();
generatingMessage.value = '';
let hasError = false;
while (!hasError) {
const { done, value } = await reader.read();
if (done) break;
try {
const lines = value.split('\n').filter(line => line.trim());
for (const line of lines) {
const event = JSON.parse(line);
if (event.type === 'start') {
generatingMessage.value = '';
} else if (event.type === 'token') {
generatingMessage.value += event.data;
} else if (event.type === 'complete') {
// Add the new regenerated message
if (event.data) {
messages.value.push(event.data);
generatingMessage.value = '';
}
} else if (event.type === 'error') {
console.error('Generation error:', event.data);
hasError = true;
}
}
} catch (parseError) {
console.error('Failed to parse event:', parseError);
}
}
appState.endGeneration();
} catch (error) {
console.error('Failed to regenerate:', error);
} finally {
loading.value = false;
}
};
const handleSelectRegeneration = (messageId: string) => {
const index = messages.value.findIndex(m => m.id === messageId);
if (index === -1) return;
// In a real app, you'd update the UI to show the selected version
// For now, just highlight it
console.log('Selected regeneration:', messageId);
};
const handleDelete = (messageId: string) => {
const index = messages.value.findIndex(m => m.id === messageId);
if (index === -1) return;
messages.value.splice(index, 1);
};
</script>
<template>
<div class="flex h-full w-full pb-4 justify-center">
<div class="max-w-4xl h-full w-full flex flex-col">
<div class="flex h-full flex-col gap-6 overflow-y-auto p-4">
<Message v-for="msg in messages" :key="msg.id" :message="msg" @regenerate="handleRegenerate"
@select="handleSelectRegeneration" @delete="handleDelete" />
<div v-if="generatingMessage" class="flex gap-3">
<div
class="flex-shrink-0 w-8 h-8 rounded-lg bg-[var(--color-neutral)] border border-[var(--color-highlight)] flex items-center justify-center">
<Icon name="mynaui:check-hexagon" class="w-4 h-4 text-[var(--color-accent)]" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-[var(--color-neutral)]">Agent</p>
<p class="text-sm text-[var(--color-text)]">{{ generatingMessage }}</p>
</div>
</div>
<p v-if="messages.length === 0 && !generatingMessage" class="text-center text-[var(--color-subtle)]">
No messages yet. Start the conversation!
</p>
</div>
<ChatInput @submit="handleSubmit" :loading="loading" />
</div>
</div>
</template>
+19 -4
View File
@@ -10,17 +10,32 @@ if (session.value !== null) {
}
const form = reactive({
name: "",
email: "",
password: "",
confirmPassword: "",
});
const loading = ref(false);
let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let tempForm = {
email: "",
password: "",
}
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
})
let hydrated = ref(false);
onMounted(() => {
form.email = tempForm.email;
form.password = tempForm.password;
hydrated.value = true;
emailInputEl.value!.addEventListener("input", () => {
emailInputEl.value!.setCustomValidity("");
});
@@ -87,8 +102,8 @@ const submit = async () => {
<label for="password">Password</label>
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
autocomplete="current-password" id="password" v-model="form.password" />
<button class="accent" type="submit">
<iconify-icons v-if="loading" width="24" icon="svg-spinners:90-ring-with-bg" />
<button :disabled="!hydrated" class="accent" type="submit">
<Icon v-if="loading" width="24" name="svg-spinners:90-ring-with-bg" />
<span v-else>Login</span>
</button>
</form>
+24 -1
View File
@@ -28,7 +28,30 @@ let emailInputEl = ref<HTMLInputElement | null>(null);
let passwordInputEl = ref<HTMLInputElement | null>(null);
let confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
let tempForm = {
name: "",
email: "",
password: "",
confirmPassword: "",
}
// prevent text fields from clearing on hydration
onBeforeMount(() => {
tempForm.name = (document.getElementById("name") as HTMLInputElement)?.value ?? "";
tempForm.email = (document.getElementById("email") as HTMLInputElement)?.value ?? "";
tempForm.password = (document.getElementById("password") as HTMLInputElement)?.value ?? "";
tempForm.confirmPassword = (document.getElementById("confirmPassword") as HTMLInputElement)?.value ?? "";
})
const hydrated = ref(false);
onMounted(() => {
form.name = tempForm.name;
form.email = tempForm.email;
form.password = tempForm.password;
form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true;
nameInputEl.value!.addEventListener("input", () => {
nameInputEl.value!.setCustomValidity("");
});
@@ -123,7 +146,7 @@ const submit = async () => {
<label for="confirmPassword">Confirm Password</label>
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword" />
<button class="accent" type="submit">
<button :disabled="!hydrated" class="accent" type="submit">
<Icon v-if="loading" class="text-6" name="svg-spinners:90-ring-with-bg" />
<span v-else>Register</span>
</button>
+3 -1
View File
@@ -96,6 +96,8 @@ onMounted(() => {
<template>
<div class="flex flex-col items-center pt-12 px-4 h-full gap-12">
<h1 class="text-center">{{ animatedText }}<span class="cursor">&nbsp;</span></h1>
<ChatInput @submit="handleChatSubmit" />
<div class="max-w-4xl h-full w-full">
<ChatInput @submit="handleChatSubmit" />
</div>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
/**
* Plugin to sync appState with route changes
* Ensures that activeAgentId and activeTopicId stay in sync with the URL
*/
export default defineNuxtPlugin(() => {
const route = useRoute();
const appState = useAppState();
// Sync agent ID from route params
watch(
() => route.params.id,
(newId) => {
if (newId) {
const agentId = Array.isArray(newId) ? newId[0] : newId;
appState.setActiveAgent(agentId);
}
},
{ immediate: true }
);
// Sync topic ID from route params
watch(
() => route.params.topicId,
(newId) => {
if (newId) {
const topicId = Array.isArray(newId) ? newId[0] : newId;
appState.setActiveTopic(topicId);
} else {
appState.setActiveTopic(null);
}
},
{ immediate: true }
);
});
+14
View File
@@ -0,0 +1,14 @@
export default defineNuxtPlugin(() => {
const route = useRoute();
const appState = useAppState();
if (route.params.id) {
const agentId = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
appState.setActiveAgent(agentId);
}
if (route.params.topicId) {
const topicId = Array.isArray(route.params.topicId) ? route.params.topicId[0] : route.params.topicId;
appState.setActiveTopic(topicId);
}
});
+8
View File
@@ -0,0 +1,8 @@
export interface DropdownItem {
label: string;
icon?: string;
onClick?: () => void;
value?: string | number;
disabled?: boolean;
divider?: boolean;
}