diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 1796ab76c0..ae35b9fcfb 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1237,7 +1237,13 @@ try { editor = meditor.create(divEl as HTMLDivElement, { - ...editorConfig(code ?? '', lang, automaticLayout, fixedOverflowWidgets, $relativeLineNumbers), + ...editorConfig( + code ?? '', + lang, + automaticLayout, + fixedOverflowWidgets, + $relativeLineNumbers + ), model, fontSize: !small ? 14 : 12, lineNumbersMinChars, @@ -1657,7 +1663,7 @@ files && model && untrack(() => onFileChanges()) }) $effect(() => { - editor?.updateOptions({ + editor?.updateOptions({ lineNumbers: $relativeLineNumbers ? 'relative' : 'on' }) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 1fe935417f..b5686be453 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -150,7 +150,6 @@ addContextToSelection(element) close() }} - categorize /> diff --git a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte index 5690a7b610..8c4e7617a4 100644 --- a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte +++ b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte @@ -3,37 +3,40 @@ import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import type { FlowModule } from '$lib/gen/types.gen' import { ContextIconMap, type ContextElement } from './context' - import { ArrowLeft, Diff, Database, Code, ChevronRight } from 'lucide-svelte' + import { ArrowLeft, Diff, Database, ChevronRight } from 'lucide-svelte' interface Props { availableContext: ContextElement[] selectedContext: ContextElement[] onSelect: (element: ContextElement) => void + setShowing?: (showing: boolean) => void showAllAvailable?: boolean stringSearch?: string - selectedIndex?: number - categorize?: boolean + onViewChange?: (newNumber: number) => void } const { availableContext, selectedContext, onSelect, + setShowing, showAllAvailable = false, stringSearch = '', - selectedIndex = 0, - categorize = false + onViewChange }: Props = $props() // Current view state: 'categories' or specific category type - let currentView = $state<'categories' | 'diffs' | 'modules' | 'databases' | 'code'>('categories') + let currentView = $state<'categories' | 'diffs' | 'modules' | 'databases'>('categories') + + // Selected index for keyboard navigation + let itemSelectedIndex = $state(0) + let categorySelectedIndex = $state(0) // Category definitions const categories = [ { id: 'diffs', label: 'Diffs', icon: Diff }, { id: 'modules', label: 'Modules', icon: BarsStaggered }, - { id: 'databases', label: 'Databases', icon: Database }, - { id: 'code', label: 'Code', icon: Code } + { id: 'databases', label: 'Databases', icon: Database } ] const filteredAvailableContext = $derived( @@ -52,15 +55,13 @@ const grouped: Record = { diffs: [], modules: [], - databases: [], - code: [] + databases: [] } filteredAvailableContext.forEach((context) => { if (context.type === 'diff') grouped.diffs.push(context) else if (context.type === 'flow_module') grouped.modules.push(context) else if (context.type === 'db') grouped.databases.push(context) - else if (context.type === 'code') grouped.code.push(context) }) return grouped @@ -70,77 +71,140 @@ currentView !== 'categories' ? contextByCategory[currentView] : [] ) + // Filter to only show categories with items + const availableCategories = $derived( + categories.filter((cat) => contextByCategory[cat.id].length > 0) + ) + + // Report view changes + $effect(() => { + if (onViewChange) { + if (currentView === 'categories') { + onViewChange(availableCategories.length) + } else { + onViewChange(currentCategoryItems.length + 1) + } + } + }) + function handleCategoryClick(categoryId: string) { currentView = categoryId as typeof currentView } function handleBackClick() { currentView = 'categories' + itemSelectedIndex = 0 } + + function handleKeyDown(e: KeyboardEvent) { + if (stringSearch.length > 0) { + // Navigation in search view (flat list) + if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + if (filteredAvailableContext.length > 0) { + itemSelectedIndex = (itemSelectedIndex + 1) % filteredAvailableContext.length + } + } else if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + if (filteredAvailableContext.length > 0) { + itemSelectedIndex = + (itemSelectedIndex - 1 + filteredAvailableContext.length) % + filteredAvailableContext.length + } + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (e.key === 'Tab') e.preventDefault() + e.stopPropagation() + const selectedItem = filteredAvailableContext[itemSelectedIndex] + if (selectedItem) { + onSelect(selectedItem) + } + } + } else if (currentView === 'categories') { + // Navigation in categories view + if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + categorySelectedIndex = (categorySelectedIndex + 1) % availableCategories.length + } else if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + categorySelectedIndex = + (categorySelectedIndex - 1 + availableCategories.length) % availableCategories.length + } else if (e.key === 'Enter' || e.key === 'ArrowRight' || e.key === 'Tab') { + e.preventDefault() + e.stopPropagation() + const selectedCategory = availableCategories[categorySelectedIndex] + if (selectedCategory) { + handleCategoryClick(selectedCategory.id) + } + } else if (e.key === 'Escape' || e.key === 'ArrowLeft') { + e.preventDefault() + e.stopPropagation() + setShowing?.(false) + } + } else { + // Navigation in category items view + if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + if (currentCategoryItems.length > 0) { + itemSelectedIndex = (itemSelectedIndex + 1) % currentCategoryItems.length + } + } else if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + if (currentCategoryItems.length > 0) { + itemSelectedIndex = + (itemSelectedIndex - 1 + currentCategoryItems.length) % currentCategoryItems.length + } + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (e.key === 'Tab') e.preventDefault() + e.stopPropagation() + const selectedItem = currentCategoryItems[itemSelectedIndex] + if (selectedItem) { + onSelect(selectedItem) + currentView = 'categories' // Go back to categories after selection + } + } else if (e.key === 'ArrowLeft' || e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + handleBackClick() + } + } + } + + // Listen for keyboard events + $effect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + } + }) + + $effect(() => { + if (stringSearch.length > 0) { + itemSelectedIndex = 0 + } + }) -
- {#if categorize} - {#if currentView === 'categories'} - {#each categories as category} - {@const itemCount = contextByCategory[category.id].length} - {@const Icon = category.icon} - {#if itemCount > 0} - - {/if} - {/each} - {#if categories.every((cat) => contextByCategory[cat.id].length === 0)} -
No available context
- {/if} - {:else} - - - - {#if currentCategoryItems.length === 0} -
No items in this category
- {:else} - {#each currentCategoryItems as element} - {@const Icon = ContextIconMap[element.type]} - - {/each} - {/if} - {/if} - {:else} +
+ // avoids triggering onblur on the textinput and closing the tooltip + e.preventDefault()} + role="listbox" + tabindex={0} +> + {#if stringSearch.length > 0} + {#each filteredAvailableContext as element, i} {@const Icon = ContextIconMap[element.type]} {/each} + {#if filteredAvailableContext.length === 0} +
No matching context
+ {/if} + {:else if currentView === 'categories'} + + {#each availableCategories as category, i} + {@const Icon = category.icon} + + {/each} + {#if availableCategories.length === 0} +
No available context
+ {/if} + {:else} + + + + {#if currentCategoryItems.length === 0} +
No items in this category
+ {:else} + {#each currentCategoryItems as element, i} + {@const Icon = ContextIconMap[element.type]} + + {/each} + {/if} {/if}
diff --git a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte index c2f98b76c3..7e12a4526e 100644 --- a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte @@ -42,7 +42,7 @@ - {contextElement.type === 'diff' || contextElement.type === 'flow_module' + {contextElement.type === 'diff' ? contextElement.title.replace(/_/g, ' ') : contextElement.title} @@ -78,7 +78,7 @@
Not loaded yet
{/if}
- {:else if contextElement.type === 'code' || contextElement.type === 'code_piece' || contextElement.type === 'diff'} + {:else if contextElement.type === 'code' || contextElement.type === 'code_piece' || contextElement.type === 'diff' || contextElement.type === 'flow_module_code_piece'}
c.type === 'code_piece' && c.title === title) + this.selectedContext.find( + (c) => + (c.type === 'code_piece' && c.title === title) || + (c.type === 'flow_module_code_piece' && c.id === moduleId && c.title === title) + ) ) { return } - this.selectedContext = [ - ...this.selectedContext, - { - type: 'code_piece', - title: title, - startLine, - endLine, - content: lines, - lang: this.scriptOptions.lang + if (moduleId) { + const module = [...this.availableContext, ...this.selectedContext].find( + (c) => c.type === 'flow_module' && c.id === moduleId + ) as FlowModuleElement + if (!module) { + console.error('Module not found', moduleId) + return } - ] + this.selectedContext = [ + ...this.selectedContext, + { + type: 'flow_module_code_piece', + id: moduleId, + title: title, + startLine, + endLine, + content: lines, + lang: this.scriptOptions.lang, + value: module.value + } + ] + } else { + this.selectedContext = [ + ...this.selectedContext, + { + type: 'code_piece', + title: title, + startLine, + endLine, + content: lines, + lang: this.scriptOptions.lang + } + ] + } } setFixContext() { diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 090437e259..ba728f6cda 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -38,7 +38,7 @@ let tooltipPosition = $state({ x: 0, y: 0 }) let textarea = $state(undefined) let tooltipElement = $state(undefined) - let selectedSuggestionIndex = $state(0) + let tooltipCurrentViewNumber = $state(0) // Properties to copy for caret position calculation const properties = [ @@ -182,27 +182,19 @@ showContextTooltip = false } - async function updateTooltipPosition( - availableContext: ContextElement[], - showContextTooltip: boolean, - contextTooltipWord: string - ) { - if (!textarea || !showContextTooltip) return + async function updateTooltipPosition(currentViewItemsNumber: number) { + if (!textarea) return try { const coords = getCaretCoordinates(textarea, textarea.selectionEnd) const rect = textarea.getBoundingClientRect() - const filteredAvailableContext = availableContext.filter( - (c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1)) - ) - const itemHeight = 28 // Estimated height of one item + gap (Button: p-1(8px) + text-xs(16px) = 24px; Parent: gap-1(4px) = 28px) const containerPadding = 8 // p-1 top + p-1 bottom = 4px + 4px = 8px const maxHeight = 192 + containerPadding // max-h-48 (192px) + containerPadding (8px) // Calculate uncapped height, subtract gap from last item as it's not needed - const numItems = filteredAvailableContext.length + const numItems = currentViewItemsNumber let uncappedHeight = numItems > 0 ? numItems * itemHeight - 4 + containerPadding : containerPadding // Ensure height is at least containerPadding even if no items @@ -270,68 +262,33 @@ } else { showContextTooltip = false contextTooltipWord = '' - selectedSuggestionIndex = 0 - } - } - - function handleKeyPress(e: KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - if (contextTooltipWord) { - const filteredContext = availableContext.filter( - (c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1)) - ) - const contextElement = filteredContext[selectedSuggestionIndex] - if (contextElement) { - const isInSelectedContext = selectedContext.find( - (c) => c.title === contextElement.title && c.type === contextElement.type - ) - // If the context element is already in the selected context and the last word in the instructions is the same as the context element title, send request - if (isInSelectedContext && value.split(' ').pop() === '@' + contextElement.title) { - onSendRequest() - return - } - handleContextSelection(contextElement) - } else if (contextTooltipWord === '@' && availableContext.length > 0) { - handleContextSelection(availableContext[0]) - } - } else { - onSendRequest() - } } } function handleKeyDown(e: KeyboardEvent) { + // Pass to parent first if provided if (onKeyDown) { onKeyDown(e) } - if (!showContextTooltip) return - - const filteredContext = availableContext.filter( - (c) => !contextTooltipWord || c.title.toLowerCase().includes(contextTooltipWord.slice(1)) - ) - - if (e.key === 'Tab') { - e.preventDefault() - const contextElement = filteredContext[selectedSuggestionIndex] - if (contextElement) { - handleContextSelection(contextElement) + if (showContextTooltip) { + // avoid new line after Enter in the tooltip + if (e.key === 'Enter') { + e.preventDefault() } + return } - if (e.key === 'ArrowDown') { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - selectedSuggestionIndex = (selectedSuggestionIndex + 1) % filteredContext.length - } else if (e.key === 'ArrowUp') { - e.preventDefault() - selectedSuggestionIndex = - (selectedSuggestionIndex - 1 + filteredContext.length) % filteredContext.length + onSendRequest() } } $effect(() => { - updateTooltipPosition(availableContext, showContextTooltip, contextTooltipWord) + if (showContextTooltip) { + updateTooltipPosition(tooltipCurrentViewNumber) + } }) export function focus() { @@ -352,7 +309,6 @@