allozaur/feat/chat slash commands (#26716)
* base : slash-command/misc foundation - model icon and focus-selector constants * feat : slash-command picker and command parsing helpers * refactor : wire command and @-mention pickers into the chat form * ui : improve model selector keyboard navigation and load/dismiss * feat: Unify markdown/raw-text rendering under one setting with migration * fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards * feat: Clamp and style numeric settings inputs from registry bounds
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
@@ -35,7 +36,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedIndex = $state<number>(-1);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -43,15 +44,77 @@
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
const len = visualOrder.length;
|
||||
if (len === 0) {
|
||||
highlightedId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = highlightedIndex;
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : len - 1;
|
||||
} else {
|
||||
index = (index + direction + len) % len;
|
||||
}
|
||||
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
void ms.handleSelect(modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = routerModels().find((m) => m.id === modelId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.unloadModel(modelId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
@@ -61,33 +124,17 @@
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
|
||||
highlightedIndex = 0;
|
||||
} else {
|
||||
highlightedIndex += 1;
|
||||
}
|
||||
moveHighlight(1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
||||
highlightedIndex = ms.filteredOptions.length - 1;
|
||||
} else {
|
||||
highlightedIndex -= 1;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
|
||||
const option = ms.filteredOptions[highlightedIndex];
|
||||
|
||||
ms.handleSelect(option.id);
|
||||
} else if (ms.filteredOptions.length > 0) {
|
||||
highlightedIndex = 0;
|
||||
if (highlightedId) {
|
||||
void handleModelKeyAction(highlightedId, event.altKey);
|
||||
} else if (visualOrder.length > 0) {
|
||||
highlightedId = visualOrder[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +156,7 @@
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
@@ -150,7 +197,7 @@
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
@@ -186,6 +233,7 @@
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
@@ -217,9 +265,9 @@
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option, flatIndex } = item}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
@@ -230,11 +278,11 @@
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
ms.handleSelect(option.id);
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -275,7 +323,7 @@
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
|
||||
@@ -62,9 +62,10 @@
|
||||
<div
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
||||
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
|
||||
Reference in New Issue
Block a user