362 lines
16 KiB
Vue
362 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import RowVirtualizerFixed from '~/components/RowVirtualizerFixed.vue';
|
|
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
|
import { DialogType } from '~/composables/useDialog';
|
|
import { exportTopicToJson, exportTopicToMarkdown } from '~~/utils/export';
|
|
|
|
const { openDialog } = useDialog();
|
|
const dropdownOpen = ref(false);
|
|
const dropdownTrigger = ref<HTMLElement | null>(null);
|
|
const dropdownContent = ref(null);
|
|
const activeMenuTopicId = ref<string | null>(null);
|
|
const exportSubmenuOpen = ref(false);
|
|
const exportTrigger = ref<HTMLElement | null>(null);
|
|
const exportContent = ref(null);
|
|
const exportInProgress = ref(false);
|
|
|
|
const route = useRoute();
|
|
const { getAgent, patchTopicLocally, deleteTopic: deleteAgentTopic } = await useAgents();
|
|
const { autoRename } = useTopic();
|
|
|
|
const navRef = ref<HTMLElement | null>(null);
|
|
const agentId = computed(() => route.params.id as string);
|
|
|
|
const activeAgent = getAgent(agentId);
|
|
watch(agentId, () => {
|
|
if (navRef.value) {
|
|
navRef.value.scrollTo({ top: 0, behavior: 'instant' });
|
|
}
|
|
}, { immediate: true });
|
|
|
|
const topics = computed(() => {
|
|
return activeAgent.value?.topics || [];
|
|
})
|
|
|
|
const { floatingStyles, placement, middlewareData } = useFloating(dropdownTrigger, dropdownContent, {
|
|
placement: 'bottom-end',
|
|
whileElementsMounted: autoUpdate,
|
|
middleware: [offset(6), flip(), shift({ padding: 10 }), hide()],
|
|
transform: false,
|
|
});
|
|
|
|
const {
|
|
floatingStyles: exportFloatingStyles,
|
|
placement: exportPlacement,
|
|
middlewareData: exportMiddlewareData,
|
|
} = useFloating(exportTrigger, exportContent, {
|
|
placement: 'right-start',
|
|
whileElementsMounted: autoUpdate,
|
|
middleware: [offset(6), flip({ fallbackPlacements: ['left-start', 'right-end', 'left-end'] }), shift({ padding: 10 }), hide()],
|
|
transform: false,
|
|
});
|
|
|
|
const transformOrigin = computed(() =>
|
|
placement.value.startsWith('top')
|
|
? 'transform-origin-bottom-center'
|
|
: 'transform-origin-top-center'
|
|
);
|
|
|
|
const exportTransformOrigin = computed(() => {
|
|
switch (exportPlacement.value.split('-')[0]) {
|
|
case 'left':
|
|
return 'transform-origin-right-center';
|
|
case 'right':
|
|
return 'transform-origin-left-center';
|
|
case 'top':
|
|
return 'transform-origin-bottom-center';
|
|
case 'bottom':
|
|
return 'transform-origin-top-center';
|
|
default:
|
|
return 'transform-origin-left-center';
|
|
}
|
|
});
|
|
|
|
const closeExportSubmenu = () => {
|
|
exportSubmenuOpen.value = false;
|
|
};
|
|
|
|
const closeDropdown = () => {
|
|
dropdownOpen.value = false;
|
|
activeMenuTopicId.value = null;
|
|
dropdownTrigger.value = null;
|
|
closeExportSubmenu();
|
|
};
|
|
|
|
const openExportSubmenu = () => {
|
|
exportSubmenuOpen.value = true;
|
|
};
|
|
|
|
// Computed to get the topic data for the currently open menu
|
|
const menuTopic = computed(() =>
|
|
topics.value.find(t => t.id === activeMenuTopicId.value)
|
|
);
|
|
|
|
const handleExport = async (format: 'json' | 'markdown') => {
|
|
if (!menuTopic.value || exportInProgress.value) return;
|
|
|
|
exportInProgress.value = true;
|
|
try {
|
|
if (format === 'json') {
|
|
await exportTopicToJson(menuTopic.value.id);
|
|
} else {
|
|
await exportTopicToMarkdown(menuTopic.value.id);
|
|
}
|
|
} finally {
|
|
exportInProgress.value = false;
|
|
closeDropdown();
|
|
}
|
|
};
|
|
|
|
const handleDropdownClickOutside = (event?: Event) => {
|
|
const target = event?.target as Node | null;
|
|
if (target) {
|
|
if (exportContent.value && (exportContent.value as HTMLElement).contains(target)) {
|
|
return;
|
|
}
|
|
if (exportTrigger.value && exportTrigger.value.contains(target)) {
|
|
return;
|
|
}
|
|
}
|
|
closeDropdown();
|
|
};
|
|
|
|
const topicsOpen = ref(true);
|
|
|
|
const autoRenameTopic = async (topicId: string) => {
|
|
autoRename(topicId);
|
|
}
|
|
|
|
const cancelAutoRename = async (topicId: string) => {
|
|
// TODO: make this more optimistic
|
|
await $fetch(`/api/topic/${topicId}/auto-rename/cancel`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
const renameTopicId = ref<string | null>(null);
|
|
const newTopicName = ref('');
|
|
|
|
const startRename = (topicId: string, currentName: string) => {
|
|
renameTopicId.value = topicId;
|
|
newTopicName.value = currentName;
|
|
nextTick(() => {
|
|
const input = document.getElementById('topic-rename-input') as HTMLInputElement | null;
|
|
if (input) {
|
|
input.focus();
|
|
}
|
|
})
|
|
};
|
|
|
|
const saveRename = async () => {
|
|
if (renameTopicId.value && newTopicName.value.trim()) {
|
|
patchTopicLocally(renameTopicId.value, { name: newTopicName.value.trim() });
|
|
|
|
$fetch(`/api/topic/${renameTopicId.value}`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
name: newTopicName.value.trim(),
|
|
},
|
|
});
|
|
}
|
|
|
|
cancelRename();
|
|
};
|
|
|
|
const cancelRename = () => {
|
|
renameTopicId.value = null;
|
|
newTopicName.value = '';
|
|
};
|
|
|
|
const deleteTopic = async (topicId: string) => {
|
|
if (route.params.topicId === topicId) {
|
|
if (agentId.value) {
|
|
await navigateTo(`/agent/${agentId.value}`);
|
|
} else {
|
|
await navigateTo('/');
|
|
}
|
|
}
|
|
|
|
deleteAgentTopic(agentId.value!, topicId);
|
|
};
|
|
|
|
const handleNavClick = (e: MouseEvent) => {
|
|
const trigger = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
|
if (!trigger) return;
|
|
|
|
const topicId = trigger.dataset.topicId || (trigger.closest('[data-topic-id]') as HTMLElement | null)?.dataset.topicId;
|
|
if (!topicId) return;
|
|
|
|
const action = trigger.dataset.action;
|
|
switch (action) {
|
|
case 'navigate':
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
e.preventDefault();
|
|
return navigateTo(`/agent/${agentId.value}/topic/${topicId}`);
|
|
|
|
case 'toggle-dropdown':
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (activeMenuTopicId.value === topicId && dropdownOpen.value) {
|
|
closeDropdown();
|
|
return;
|
|
}
|
|
|
|
activeMenuTopicId.value = topicId;
|
|
dropdownTrigger.value = trigger;
|
|
dropdownOpen.value = true;
|
|
closeExportSubmenu();
|
|
break;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// simply preload the topic page
|
|
preloadRouteComponents(`/agent/${agentId.value}/topic/42`);
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<nav ref="navRef"
|
|
class="flex flex-col gap-1 max-h-full h-full overflow-auto [scrollbar-color:#888_transparent] [scrollbar-width:thin] [scrollbar-gutter:stable]">
|
|
|
|
<!-- Agent Info Link -->
|
|
<SidenavItem :to="`/agent/${agentId}/profile`" name="Agent Info" icon="i-mynaui-info-square"
|
|
:active="route.path.endsWith('/profile')" />
|
|
|
|
<SidenavItem @click="openDialog(DialogType.QuickSwitcher)" name="Search" icon="i-mynaui-search">
|
|
<div class="flex items-center gap-1">
|
|
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
|
<kbd class="font-mono text-[10px] case-capital">ctrl</kbd>
|
|
</span>
|
|
<span class="flex bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
|
<kbd class="font-mono text-[10px] case-capital">k</kbd>
|
|
</span>
|
|
</div>
|
|
</SidenavItem>
|
|
|
|
<!-- Topics Section -->
|
|
<button @click="topicsOpen = !topicsOpen"
|
|
class="flex items-center justify-between gap-2 px-2 py-2 rounded-lg bg-transparent @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] transition-colors w-full text-left">
|
|
<span class="md:text-sm font-medium">Topics</span>
|
|
<span
|
|
class="i-mynaui-chevron-down inline-block text-5 md:text-4 transition-transform duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
|
:class="topicsOpen ? '' : '-rotate-90'"></span>
|
|
</button>
|
|
|
|
<Collapsible :is-open="topicsOpen">
|
|
<div @click="handleNavClick" class="flex flex-col transform-origin-center-top">
|
|
<RowVirtualizerFixed :scroll-element="navRef" key-field="id" :prerender="50" :items="topics"
|
|
:item-size="40" :overscan="20">
|
|
<template v-slot="{ item: topic }">
|
|
<a :key="topic.id" data-action="navigate" :data-topic-id="topic.id"
|
|
:href="`/agent/${agentId}/topic/${topic.id}`" :aria-label="topic.name"
|
|
class="group mt-1 px-2 decoration-none flex justify-between items-center shrink-0 rounded-lg transition-colors cursor-pointer h-9 text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] focus:text-[var(--text-primary)]"
|
|
:class="{ 'bg-[var(--color-hover)]': route.params.topicId === topic.id }">
|
|
<input v-if="renameTopicId === topic.id && !topic.renaming" id="topic-rename-input"
|
|
v-model="newTopicName" @keydown.enter="saveRename" @keydown.escape="cancelRename"
|
|
@blur="saveRename"
|
|
class="flex-1 bg-transparent border-none outline-none md:text-sm font-medium text-[var(--text-primary)] px-0 min-w-0" />
|
|
<div v-else-if="topic.renaming" class="flex w-full">
|
|
<span class="i-svg-spinners-3-dots-fade text-7 md:text-6"></span>
|
|
</div>
|
|
<span v-else class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
|
{{ topic.name }}
|
|
</span>
|
|
|
|
<div data-action="toggle-dropdown"
|
|
class="text-[var(--text-secondary)] shrink-0 w-0 max-w-0 opacity-0 overflow-hidden p-1 max-md:w-auto max-md:max-w-none max-md:opacity-100 flex items-center justify-center rounded-md @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]"
|
|
:class="(route.params.topicId === topic.id || (dropdownOpen && activeMenuTopicId === topic.id)) ? 'w-auto max-w-none opacity-100' : 'group-hover:w-auto group-hover:max-w-none group-hover:opacity-100'">
|
|
<span class="pointer-events-none h-6 w-6 md:h-4.5 md:w-4.5 i-tabler-dots"></span>
|
|
</div>
|
|
</a>
|
|
</template>
|
|
</RowVirtualizerFixed>
|
|
</div>
|
|
</Collapsible>
|
|
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
|
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
|
|
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
|
|
leave-from-class="opacity-100 scale-100 translate-y-0"
|
|
leave-to-class="opacity-0 scale-95 translate-y-1">
|
|
<div v-if="dropdownOpen" ref="dropdownContent" v-click-outside="handleDropdownClickOutside" :style="{
|
|
...floatingStyles,
|
|
visibility: middlewareData.hide?.referenceHidden
|
|
? 'hidden'
|
|
: 'visible',
|
|
}" class="fixed z-20" :class="transformOrigin">
|
|
<div
|
|
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-40">
|
|
|
|
<!-- Dynamic content based on menuTopic -->
|
|
<template v-if="menuTopic">
|
|
<button v-if="menuTopic.renaming" @click="cancelAutoRename(menuTopic.id); closeDropdown()"
|
|
@mouseenter="closeExportSubmenu"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
|
Cancel Auto Rename
|
|
</button>
|
|
<button v-else @click="autoRenameTopic(menuTopic.id); closeDropdown()"
|
|
@mouseenter="closeExportSubmenu"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
|
Auto Rename
|
|
</button>
|
|
|
|
<button :disabled="menuTopic.renaming ?? false"
|
|
@click="startRename(menuTopic.id, menuTopic.name); closeDropdown()"
|
|
@mouseenter="closeExportSubmenu"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50">
|
|
Rename
|
|
</button>
|
|
|
|
<button ref="exportTrigger" @click.stop="exportSubmenuOpen = !exportSubmenuOpen"
|
|
@mouseenter="openExportSubmenu"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 flex items-center justify-between gap-3"
|
|
:class="{ 'bg-[var(--color-hover)]': exportSubmenuOpen }"
|
|
:disabled="exportInProgress">
|
|
<span>Export</span>
|
|
<span class="i-mynaui-chevron-right text-4 shrink-0"
|
|
:class="exportPlacement.startsWith('left') ? 'rotate-180' : ''"></span>
|
|
</button>
|
|
|
|
<div class="h-px bg-[var(--color-border)] my-1" />
|
|
|
|
<button @click="deleteTopic(menuTopic.id); closeDropdown()"
|
|
@mouseenter="closeExportSubmenu"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 text-red-500">
|
|
Delete
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<Transition
|
|
enter-active-class="transition-[opacity,transform] 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-[opacity,transform] 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="dropdownOpen && exportSubmenuOpen" ref="exportContent" :style="{
|
|
...exportFloatingStyles,
|
|
visibility: exportMiddlewareData.hide?.referenceHidden
|
|
? 'hidden'
|
|
: 'visible',
|
|
}" class="fixed z-30" :class="exportTransformOrigin"
|
|
@mouseenter="openExportSubmenu">
|
|
<div
|
|
class="bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 shadow-xl flex flex-col gap-1 min-w-44">
|
|
<button :disabled="exportInProgress" @click="handleExport('json')"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
|
|
Export to JSON
|
|
</button>
|
|
<button :disabled="exportInProgress" @click="handleExport('markdown')"
|
|
class="text-left px-3 py-1.5 text-sm rounded-lg @hover:bg-[var(--color-hover)] transition-colors disabled:opacity-50 whitespace-nowrap">
|
|
Export to Markdown
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</nav>
|
|
</template> |