feat: quick switcher
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Err, Ok, type Result } from "~~/types/result";
|
||||
export enum DialogType {
|
||||
Settings = 'settings',
|
||||
Textbox = 'textbox',
|
||||
QuickSwitcher = 'quick-switcher',
|
||||
Confirm = 'confirm',
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user