feat: quick switcher

This commit is contained in:
Zoe
2026-02-26 14:51:47 -06:00
parent 97afa1cbe1
commit bf9f613e4d
6 changed files with 201 additions and 7 deletions
+175
View File
@@ -0,0 +1,175 @@
<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);
console.log(itemsPerPage);
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-[55vw] 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="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 mt-2 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(--color-border)]"></div>
<div class="absolute -left-4 top-5 w-3 h-[1px] bg-[var(--color-border)]"></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>
+1 -1
View File
@@ -70,7 +70,7 @@ const runtimePage = computed(() => {
</script>
<template>
<div class="p-2 flex w-full">
<div class="p-2 flex h-[70vh] h-[70dvh] w-[85vw]">
<nav class="w-64 flex flex-col gap-1 mr-2 overflow-y-auto">
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar"
+1 -1
View File
@@ -9,7 +9,7 @@ const text = ref(props.initialValue || '');
</script>
<template>
<div class="flex flex-col w-full">
<div class="flex flex-col h-[70vh] h-[70dvh] w-[85vw]">
<h3 class="m-4" v-if="title">{{ title }}</h3>
<textarea v-model="text" class="bg-[var(--bg-surface)] resize-none h-full w-full p-2 rounded" />
<div class="flex justify-end gap-2 m-2">
+16 -4
View File
@@ -1,16 +1,25 @@
<script setup lang="ts">
import Textbox from './Textbox.vue';
import Settings from './Settings.vue';
import QuickSwitcher from './QuickSwitcher.vue';
import { DialogType } from '~/composables/useDialog';
const { open, page, data, close, confirm, openDialog } = useDialog();
const { addShortcut } = useKeyboardShortcuts();
const unbindShortcut = addShortcut(['ctrl', ','], () => {
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
event.preventDefault();
event.stopPropagation();
openDialog(DialogType.Settings);
});
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
event.preventDefault();
event.stopPropagation();
openDialog(DialogType.QuickSwitcher);
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close();
@@ -29,7 +38,8 @@ onUnmounted(() => {
if (open.value) {
document.body.removeEventListener('keydown', handleKeyDown);
}
unbindShortcut();
unbindSettingsShortcut();
unbindQuickSwitcherShortcut();
});
</script>
@@ -44,12 +54,14 @@ onUnmounted(() => {
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-[85vw] max-w-6xl h-[70vh] bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)]
overflow-hidden flex max-h-[90vh]">
<div
class="absolute max-w-6xl bg-[var(--bg-base)] rounded-2xl shadow-2xl border border-[var(--color-border)] flex">
<KeepAlive>
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
</KeepAlive>
</div>
</div>
+7 -1
View File
@@ -25,13 +25,19 @@ const rowVirtualizer = useVirtualizer(computed(() => ({
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize());
defineExpose({
scrollToIndex: (index: number, options?: { align?: 'start' | 'center' | 'end' }) => {
rowVirtualizer.value.scrollToIndex(index, options);
}
})
watch(() => props.items, () => {
rowVirtualizer.value.measure();
}, { deep: false });
</script>
<template>
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }">
<div :style="{ height: `${totalSize}px`, width: '100%', position: 'relative' }" v-bind="$attrs">
<div v-for="virtualRow in virtualRows" :key="(virtualRow.key as any | number)" :style="{
position: 'absolute',
top: 0,
+1
View File
@@ -3,6 +3,7 @@ import { Err, Ok, type Result } from "~~/types/result";
export enum DialogType {
Settings = 'settings',
Textbox = 'textbox',
QuickSwitcher = 'quick-switcher',
Confirm = 'confirm',
}