diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 457e315d94..6765a5bb7b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -110,30 +110,39 @@ } function addContextToSelection(contextElement: ContextElement) { + if (!selectedContext || !availableContext) return + + const alreadySelected = selectedContext.find( + (c) => c.type === contextElement.type && c.title === contextElement.title + ) + if (alreadySelected) return + + // Workspace items are fetched on-demand and not in availableContext, + // so skip the availableContext check for them + const isWorkspaceItem = + contextElement.type === 'workspace_script' || contextElement.type === 'workspace_flow' if ( - selectedContext && - availableContext && - !selectedContext.find( - (c) => c.type === contextElement.type && c.title === contextElement.title - ) && - availableContext.find( + !isWorkspaceItem && + !availableContext.find( (c) => c.type === contextElement.type && c.title === contextElement.title ) ) { - selectedContext = [...selectedContext, contextElement] + return + } - // If it's a datatable table, add it to the app's whitelisted tables - if ( - contextElement.type === 'app_datatable' && - aiChatManager.mode === AIMode.APP && - aiChatManager.appAiChatHelpers - ) { - aiChatManager.appAiChatHelpers.addTableToWhitelist( - contextElement.datatableName, - contextElement.schemaName, - contextElement.tableName - ) - } + selectedContext = [...selectedContext, contextElement] + + // If it's a datatable table, add it to the app's whitelisted tables + if ( + contextElement.type === 'app_datatable' && + aiChatManager.mode === AIMode.APP && + aiChatManager.appAiChatHelpers + ) { + aiChatManager.appAiChatHelpers.addTableToWhitelist( + contextElement.datatableName, + contextElement.schemaName, + contextElement.tableName + ) } } @@ -368,6 +377,10 @@ addContextToSelection(element) close() }} + onSelectWorkspaceItem={(element) => { + addContextToSelection(element) + close() + }} /> diff --git a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte index 241643ca4e..c8667d4cf6 100644 --- a/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte +++ b/frontend/src/lib/components/copilot/chat/AvailableContextList.svelte @@ -2,13 +2,28 @@ import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte' 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, ChevronRight } from 'lucide-svelte' + import { workspaceStore } from '$lib/stores' + import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared' + import { + ContextIconMap, + type ContextElement, + type WorkspaceScriptElement, + type WorkspaceFlowElement + } from './context' + import { + ArrowLeft, + Diff, + Database, + ChevronRight, + Code2, + Loader2 + } from 'lucide-svelte' interface Props { availableContext: ContextElement[] selectedContext: ContextElement[] onSelect: (element: ContextElement) => void + onSelectWorkspaceItem?: (element: ContextElement) => void setShowing?: (showing: boolean) => void showAllAvailable?: boolean stringSearch?: string @@ -19,6 +34,7 @@ availableContext, selectedContext, onSelect, + onSelectWorkspaceItem, setShowing, showAllAvailable = false, stringSearch = '', @@ -26,19 +42,34 @@ }: Props = $props() // Current view state: 'categories' or specific category type - let currentView = $state<'categories' | 'diffs' | 'modules' | 'databases'>('categories') + let currentView = $state< + 'categories' | 'diffs' | 'modules' | 'databases' | 'scripts' | 'flows' + >('categories') // Selected index for keyboard navigation let itemSelectedIndex = $state(0) let categorySelectedIndex = $state(0) + // Workspace search state + let workspaceSearchQuery = $state('') + let workspaceSearchResults = $state<{ path: string; summary: string }[]>([]) + let workspaceSearchLoading = $state(false) + let searchInputElement = $state(undefined) + let searchDebounceTimer: ReturnType | undefined = undefined + // Category definitions const categories = [ - { id: 'diffs', label: 'Diffs', icon: Diff }, - { id: 'modules', label: 'Modules', icon: BarsStaggered }, - { id: 'databases', label: 'Databases', icon: Database } + { id: 'diffs' as const, label: 'Diffs', icon: Diff, searchable: false }, + { id: 'modules' as const, label: 'Modules', icon: BarsStaggered, searchable: false }, + { id: 'databases' as const, label: 'Databases', icon: Database, searchable: false }, + { id: 'scripts' as const, label: 'Scripts', icon: Code2, searchable: true }, + { id: 'flows' as const, label: 'Flows', icon: BarsStaggered, searchable: true } ] + const isSearchableView = $derived( + currentView === 'scripts' || currentView === 'flows' + ) + const filteredAvailableContext = $derived( availableContext.filter((context) => { const filtered = @@ -68,12 +99,14 @@ }) const currentCategoryItems = $derived( - currentView !== 'categories' ? contextByCategory[currentView] : [] + currentView !== 'categories' && !isSearchableView ? contextByCategory[currentView] : [] ) - // Filter to only show categories with items + // Filter to only show categories with items (non-searchable) or always show (searchable) const availableCategories = $derived( - categories.filter((cat) => contextByCategory[cat.id].length > 0) + categories.filter( + (cat) => cat.searchable || contextByCategory[cat.id]?.length > 0 + ) ) // Report view changes @@ -81,6 +114,8 @@ if (onViewChange) { if (currentView === 'categories') { onViewChange(availableCategories.length) + } else if (isSearchableView) { + onViewChange(workspaceSearchResults.length + 2) // +2 for back button and search input } else { onViewChange(currentCategoryItems.length + 1) } @@ -89,15 +124,126 @@ function handleCategoryClick(categoryId: string) { currentView = categoryId as typeof currentView + itemSelectedIndex = 0 + if (categoryId === 'scripts' || categoryId === 'flows') { + workspaceSearchQuery = '' + workspaceSearchResults = [] + setTimeout(() => searchInputElement?.focus(), 0) + } } function handleBackClick() { currentView = 'categories' itemSelectedIndex = 0 + workspaceSearchQuery = '' + workspaceSearchResults = [] + } + + async function searchWorkspaceItems(query: string) { + const workspace = $workspaceStore + if (!workspace) return + + workspaceSearchLoading = true + try { + const type = currentView === 'scripts' ? 'scripts' : 'flows' + const results = await workspaceRunnablesSearch.search(query, workspace, type) + workspaceSearchResults = results.map((r) => ({ + path: r.path, + summary: r.summary + })) + } catch (err) { + console.error('Error searching workspace items', err) + workspaceSearchResults = [] + } finally { + workspaceSearchLoading = false + } + } + + function handleSearchInput() { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) + searchDebounceTimer = setTimeout(() => { + searchWorkspaceItems(workspaceSearchQuery) + }, 300) + } + + async function handleWorkspaceItemSelect(path: string) { + const workspace = $workspaceStore + if (!workspace || !onSelectWorkspaceItem) return + + try { + if (currentView === 'scripts') { + const script = await workspaceRunnablesSearch.getScript(path, workspace) + const element: WorkspaceScriptElement & { deletable: boolean } = { + type: 'workspace_script', + path: script.path, + title: script.path, + summary: script.summary, + language: script.language, + content: script.content, + schema: script.schema, + deletable: true + } + onSelectWorkspaceItem(element) + } else if (currentView === 'flows') { + const flow = await workspaceRunnablesSearch.getFlow(path, workspace) + const flowValue = JSON.stringify(flow.value, null, 2) + const truncatedValue = + flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH + ? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)' + : flowValue + const element: WorkspaceFlowElement & { deletable: boolean } = { + type: 'workspace_flow', + path: flow.path, + title: flow.path, + summary: flow.summary, + description: flow.description || '', + value: truncatedValue, + schema: flow.schema, + deletable: true + } + onSelectWorkspaceItem(element) + } + } catch (err) { + console.error('Error fetching workspace item', err) + } + currentView = 'categories' + workspaceSearchQuery = '' + workspaceSearchResults = [] } function handleKeyDown(e: KeyboardEvent) { - if (stringSearch.length > 0) { + if (isSearchableView) { + // Navigation in workspace search view + if (e.key === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + if (workspaceSearchResults.length > 0) { + itemSelectedIndex = (itemSelectedIndex + 1) % workspaceSearchResults.length + } + } else if (e.key === 'ArrowUp') { + e.preventDefault() + e.stopPropagation() + if (workspaceSearchResults.length > 0) { + itemSelectedIndex = + (itemSelectedIndex - 1 + workspaceSearchResults.length) % + workspaceSearchResults.length + } + } else if (e.key === 'Enter') { + // Only select if not typing in the search input, or if results exist + if (workspaceSearchResults.length > 0) { + e.preventDefault() + e.stopPropagation() + const selectedItem = workspaceSearchResults[itemSelectedIndex] + if (selectedItem) { + handleWorkspaceItemSelect(selectedItem.path) + } + } + } else if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + handleBackClick() + } + } else if (stringSearch.length > 0) { // Navigation in search view (flat list) if (e.key === 'ArrowDown') { e.preventDefault() @@ -188,13 +334,24 @@ itemSelectedIndex = 0 } }) + + // Trigger initial search when entering a searchable category + $effect(() => { + if (isSearchableView) { + searchWorkspaceItems('') + } + })
+ onmousedown={(e) => { // avoids triggering onblur on the textinput and closing the tooltip - e.preventDefault()} + // but allow input elements to receive focus for the search input + if (!(e.target instanceof HTMLInputElement)) { + e.preventDefault() + } + }} role="listbox" tabindex={0} > @@ -245,6 +402,68 @@ {#if availableCategories.length === 0}
No available context
{/if} + {:else if isSearchableView} + + + + + + {#if workspaceSearchLoading} +
+ + Searching... +
+ {:else if workspaceSearchResults.length === 0} +
+ No results found +
+ {:else} + {#each workspaceSearchResults as item, i} + {@const isAlreadySelected = selectedContext.some( + (c) => + ((c.type === 'workspace_script' && currentView === 'scripts') || + (c.type === 'workspace_flow' && currentView === 'flows')) && + c.title === item.path + )} + + {/each} + {/if} {:else}