Files
veridian/app/components/Dialog/QuickSwitcher.vue
T

175 lines
7.7 KiB
Vue

<script setup lang="ts">
import { useAgents } from '~/composables/useAgents';
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
const { agents } = useAgents();
const route = useRoute();
const emit = defineEmits(['close']);
const query = ref('');
const selectedIndex = ref(0);
const navRef = ref<HTMLElement | null>(null);
const inputRef = ref<HTMLInputElement | null>(null);
const virtualizerRef = ref<typeof RowVirtualizerFixed | null>(null); // Ref for the virtualizer component
// Generate unique ID for ARIA compliance
const listboxId = useId();
const flattenedResults = computed(() => {
const q = query.value.toLowerCase().trim();
const activeAgentId = route.params.id as string;
const sortedAgents = [...agents.value].sort((a, b) => {
if (a.id === activeAgentId) return -1;
if (b.id === activeAgentId) return 1;
return 0;
});
const list: any[] = [];
sortedAgents.forEach(agent => {
const agentMatches = agent.name.toLowerCase().includes(q);
const matchedTopics = agent.topics?.filter(t => t.name.toLowerCase().includes(q)) || [];
if (agentMatches || matchedTopics.length > 0) {
list.push({ type: 'agent', ...agent, uiId: `agent-${agent.id}` });
matchedTopics.forEach(topic => {
list.push({ type: 'topic', ...topic, agentId: agent.id, uiId: `topic-${topic.id}` });
});
}
});
return list;
});
// Scroll to index whenever selectedIndex changes
watch(selectedIndex, (index) => {
if (virtualizerRef.value?.scrollToIndex) {
virtualizerRef.value.scrollToIndex(index, { align: 'center' });
}
});
watch(query, (newQuery) => {
if (newQuery.length > 0) {
const firstTopicIdx = flattenedResults.value.findIndex(i => i.type === 'topic');
selectedIndex.value = firstTopicIdx !== -1 ? firstTopicIdx : 0;
} else {
selectedIndex.value = 0;
}
// Ensure we scroll back to top on new search
virtualizerRef.value?.scrollToIndex?.(0);
});
const selectItem = (uiId?: string) => {
const item = uiId ? flattenedResults.value.find(i => i.uiId === uiId) : flattenedResults.value[selectedIndex.value];
if (!item) return;
emit('close');
if (item.type === 'topic') {
return navigateTo(`/agent/${item.agentId}/topic/${item.id}`);
} else {
return navigateTo(`/agent/${item.id}`);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
const len = flattenedResults.value.length;
if (len === 0) return;
// 60% of the window height aka 60vh
const itemsPerPage = Math.floor(((window.innerHeight * 0.6) - (inputRef.value?.parentElement?.clientHeight || 0)) / 42);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % len;
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex.value = (selectedIndex.value - 1 + len) % len;
break;
case 'PageDown':
e.preventDefault();
selectedIndex.value = (selectedIndex.value + itemsPerPage) % len;
break;
case 'PageUp':
e.preventDefault();
selectedIndex.value = (selectedIndex.value - itemsPerPage + len) % len;
break;
case 'Home':
e.preventDefault();
selectedIndex.value = 0;
break;
case 'End':
e.preventDefault();
selectedIndex.value = len - 1;
break;
case 'Enter':
e.preventDefault();
selectItem();
break;
case 'Escape':
emit('close');
break;
}
};
onMounted(() => { inputRef.value?.focus(); });
</script>
<template>
<div
class="flex flex-col w-[100vw] max-w-4xl max-h-[60vh] bg-[var(--color-bg-elevated)] rounded-xl shadow-2xl border border-[var(--color-border)] overflow-hidden">
<!-- Search -->
<div class="p-4 border-b border-[var(--color-border)]">
<input ref="inputRef" v-model="query" type="text" role="combobox" aria-autocomplete="list"
aria-haspopup="listbox" :aria-expanded="flattenedResults.length > 0" :aria-controls="listboxId"
:aria-activedescendant="flattenedResults[selectedIndex]?.uiId" placeholder="Search agents and topics..."
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent border-none outline-none text-lg text-[var(--text-primary)]"
@keydown="handleKeyDown" />
</div>
<div ref="navRef"
class="flex-1 overflow-y-auto p-2 [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<RowVirtualizerFixed ref="virtualizerRef" :scroll-element="navRef" key-field="uiId"
:items="flattenedResults" :item-size="42" :overscan="10" role="listbox" :id="listboxId">
<template v-slot="{ item, index }">
<!-- AGENT ROW -->
<div v-if="item.type === 'agent'" :id="item.uiId" role="option"
:aria-selected="selectedIndex === index" @click="selectItem(item.uiId)"
class="group flex items-center px-3 h-10 rounded-md cursor-pointer transition-colors hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<div
:class="['mr-2 w-[28px] h-[28px] flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', item?.imageUrl ? '' : 'border border-[var(--color-border)]']">
<img v-if="item?.imageUrl" :src="item.imageUrl" class="w-full h-full object-cover" />
<span v-else class="h-4 w-4 i-mynaui-check-hexagon text-[var(--color-accent)]"></span>
</div>
<span class="text-sm font-semibold truncate">{{ item.name }}</span>
<span v-if="selectedIndex === index" class="ml-auto text-[10px] opacity-40">AGENT</span>
</div>
<!-- TOPIC ROW -->
<div v-else :id="item.uiId" role="option" :aria-selected="selectedIndex === index"
@click="selectItem(item.uiId)"
class="group flex items-center h-10 pr-3 rounded-md cursor-pointer transition-colors relative ml-10 hover:bg-[var(--color-hover)]"
:class="selectedIndex === index ? 'bg-[var(--color-hover)] text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
<!-- Visual Tree Lines -->
<div class="absolute -left-4 top-0 bottom-0 w-[1px] h-[110%] bg-[var(--text-dim)]"></div>
<div class="absolute -left-4 top-5 w-3 h-[1px] bg-[var(--text-dim)]"></div>
<span class="i-mynaui-hash mx-2 shrink-0 text-[var(--text-dim)] w-4 h-4"></span>
<span class="text-sm truncate">{{ item.name }}</span>
<div v-if="selectedIndex === index" class="ml-auto flex items-center gap-2">
<span class="text-[10px] opacity-40 font-mono italic">JUMP</span>
<span class="i-tabler-arrow-narrow-right text-xs opacity-40"></span>
</div>
</div>
</template>
</RowVirtualizerFixed>
<!-- Empty State -->
<div v-if="flattenedResults.length === 0" class="p-10 text-center text-[var(--text-secondary)] text-sm">
No matching agents or topics found.
</div>
</div>
</div>
</template>