mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
feat: allow @ selection for raw apps
This commit is contained in:
@@ -98,7 +98,9 @@
|
||||
() => aiChatManager.contextManager.getSelectedContext(),
|
||||
(sc) => aiChatManager.contextManager.setSelectedContext(sc)
|
||||
}
|
||||
availableContext={aiChatManager.contextManager.getAvailableContext()}
|
||||
availableContext={aiChatManager.mode === AIMode.APP
|
||||
? aiChatManager.getAppAvailableContext()
|
||||
: aiChatManager.contextManager.getAvailableContext()}
|
||||
messages={aiChatManager.currentReply
|
||||
? [
|
||||
...aiChatManager.displayMessages,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import AvailableContextList from './AvailableContextList.svelte'
|
||||
import AppAvailableContextList from './AppAvailableContextList.svelte'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import ContextTextarea from './ContextTextarea.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
@@ -8,6 +9,9 @@
|
||||
import { aiChatManager, AIMode } from './AIChatManager.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Snippet } from 'svelte'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { zIndexes } from '$lib/zIndexes'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
@@ -75,6 +79,13 @@
|
||||
let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
|
||||
let instructions = $state(initialInstructions)
|
||||
|
||||
// App mode @ mention state
|
||||
let showAppContextTooltip = $state(false)
|
||||
let appContextTooltipWord = $state('')
|
||||
let appTooltipPosition = $state({ x: 0, y: 0 })
|
||||
let appTooltipElement = $state<HTMLDivElement | undefined>(undefined)
|
||||
let appTooltipCurrentViewNumber = $state(0)
|
||||
|
||||
export function focusInput() {
|
||||
if (aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW) {
|
||||
contextTextareaComponent?.focus()
|
||||
@@ -110,6 +121,19 @@
|
||||
)
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +155,198 @@
|
||||
focusInput()
|
||||
}
|
||||
})
|
||||
|
||||
// Properties to copy for caret position calculation (app mode)
|
||||
const caretProperties = [
|
||||
'direction',
|
||||
'boxSizing',
|
||||
'width',
|
||||
'height',
|
||||
'overflowX',
|
||||
'overflowY',
|
||||
'borderTopWidth',
|
||||
'borderRightWidth',
|
||||
'borderBottomWidth',
|
||||
'borderLeftWidth',
|
||||
'borderStyle',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'fontStyle',
|
||||
'fontVariant',
|
||||
'fontWeight',
|
||||
'fontStretch',
|
||||
'fontSize',
|
||||
'fontSizeAdjust',
|
||||
'lineHeight',
|
||||
'fontFamily',
|
||||
'textAlign',
|
||||
'textTransform',
|
||||
'textIndent',
|
||||
'textDecoration',
|
||||
'letterSpacing',
|
||||
'wordSpacing',
|
||||
'tabSize',
|
||||
'MozTabSize'
|
||||
]
|
||||
|
||||
function getCaretCoordinates(element: HTMLTextAreaElement, position: number) {
|
||||
const div = document.createElement('div')
|
||||
div.id = 'input-textarea-caret-position-mirror-div'
|
||||
document.body.appendChild(div)
|
||||
|
||||
const style = div.style
|
||||
const computed = window.getComputedStyle(element)
|
||||
const isInput = element.nodeName === 'INPUT'
|
||||
|
||||
style.whiteSpace = 'pre-wrap'
|
||||
if (!isInput) style.wordWrap = 'break-word'
|
||||
|
||||
style.position = 'absolute'
|
||||
style.visibility = 'hidden'
|
||||
|
||||
caretProperties.forEach(function (prop) {
|
||||
if (isInput && prop === 'lineHeight') {
|
||||
if (computed.boxSizing === 'border-box') {
|
||||
const height = parseInt(computed.height)
|
||||
const outerHeight =
|
||||
parseInt(computed.paddingTop) +
|
||||
parseInt(computed.paddingBottom) +
|
||||
parseInt(computed.borderTopWidth) +
|
||||
parseInt(computed.borderBottomWidth)
|
||||
const targetHeight = outerHeight + parseInt(computed.lineHeight)
|
||||
if (height > targetHeight) {
|
||||
style.lineHeight = height - outerHeight + 'px'
|
||||
} else if (height === targetHeight) {
|
||||
style.lineHeight = computed.lineHeight
|
||||
} else {
|
||||
style.lineHeight = '0'
|
||||
}
|
||||
} else {
|
||||
style.lineHeight = computed.height
|
||||
}
|
||||
} else {
|
||||
style[prop] = computed[prop]
|
||||
}
|
||||
})
|
||||
|
||||
const isFirefox =
|
||||
(window as typeof window & { mozInnerScreenX: number }).mozInnerScreenX != null
|
||||
if (isFirefox) {
|
||||
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'
|
||||
} else {
|
||||
style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
div.textContent = element.value.substring(0, position)
|
||||
|
||||
if (isInput) div.textContent = div.textContent.replace(/\s/g, '\u00a0')
|
||||
|
||||
const span = document.createElement('span')
|
||||
span.textContent = element.value.substring(position) || '.'
|
||||
div.appendChild(span)
|
||||
|
||||
const coordinates = {
|
||||
top: span.offsetTop + parseInt(computed['borderTopWidth']),
|
||||
left: span.offsetLeft + parseInt(computed['borderLeftWidth']),
|
||||
height: parseInt(computed['lineHeight'])
|
||||
}
|
||||
|
||||
document.body.removeChild(div)
|
||||
|
||||
return coordinates
|
||||
}
|
||||
|
||||
async function updateAppTooltipPosition(currentViewItemsNumber: number) {
|
||||
if (!instructionsTextareaComponent) return
|
||||
|
||||
try {
|
||||
const coords = getCaretCoordinates(
|
||||
instructionsTextareaComponent,
|
||||
instructionsTextareaComponent.selectionEnd
|
||||
)
|
||||
const rect = instructionsTextareaComponent.getBoundingClientRect()
|
||||
|
||||
const itemHeight = 28
|
||||
const containerPadding = 8
|
||||
const maxHeight = 192 + containerPadding
|
||||
|
||||
const numItems = currentViewItemsNumber
|
||||
let uncappedHeight =
|
||||
numItems > 0 ? numItems * itemHeight - 4 + containerPadding : containerPadding
|
||||
uncappedHeight = Math.max(uncappedHeight, containerPadding)
|
||||
|
||||
const estimatedTooltipHeight = Math.min(uncappedHeight, maxHeight)
|
||||
const margin = 6
|
||||
|
||||
let finalX = rect.left + coords.left - 70
|
||||
let finalY: number
|
||||
|
||||
if (isFirstMessage) {
|
||||
finalY = rect.top + coords.top + coords.height - 3
|
||||
} else {
|
||||
finalY = rect.top + coords.top - estimatedTooltipHeight - margin
|
||||
}
|
||||
|
||||
appTooltipPosition = {
|
||||
x: finalX,
|
||||
y: finalY
|
||||
}
|
||||
|
||||
await tick()
|
||||
|
||||
if (appTooltipElement) {
|
||||
const tooltipRect = appTooltipElement.getBoundingClientRect()
|
||||
const tooltipWidth = tooltipRect.width
|
||||
|
||||
if (finalX + tooltipWidth > window.innerWidth) {
|
||||
finalX = Math.max(10, window.innerWidth - tooltipWidth - 10)
|
||||
|
||||
appTooltipPosition = {
|
||||
x: finalX,
|
||||
y: finalY
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating tooltip position', error)
|
||||
showAppContextTooltip = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppInput(_e: Event) {
|
||||
const words = instructions.split(/\s+/)
|
||||
const lastWord = words[words.length - 1]
|
||||
|
||||
if (
|
||||
lastWord.startsWith('@') &&
|
||||
(!availableContext.find((c) => c.title === lastWord.slice(1)) ||
|
||||
!selectedContext.find((c) => c.title === lastWord.slice(1)))
|
||||
) {
|
||||
showAppContextTooltip = true
|
||||
appContextTooltipWord = lastWord
|
||||
} else {
|
||||
showAppContextTooltip = false
|
||||
appContextTooltipWord = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppContextSelection(contextElement: ContextElement) {
|
||||
addContextToSelection(contextElement)
|
||||
// Update instructions with the selected context title
|
||||
const index = instructions.lastIndexOf('@')
|
||||
if (index !== -1) {
|
||||
instructions = instructions.substring(0, index) + `@${contextElement.title}`
|
||||
}
|
||||
showAppContextTooltip = false
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showAppContextTooltip) {
|
||||
updateAppTooltipPosition(appTooltipCurrentViewNumber)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div use:clickOutside class="relative">
|
||||
@@ -185,6 +401,98 @@
|
||||
{disabled}
|
||||
{onKeyDown}
|
||||
/>
|
||||
{:else if aiChatManager.mode === AIMode.APP}
|
||||
{#if showContext}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
|
||||
<Popover>
|
||||
<svelte:fragment slot="trigger">
|
||||
<div
|
||||
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
|
||||
>@</div
|
||||
>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content" let:close>
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
addContextToSelection(element)
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{#each selectedContext as element (element.type + '-' + element.title)}
|
||||
<ContextElementBadge
|
||||
contextElement={element}
|
||||
deletable
|
||||
onDelete={() => {
|
||||
selectedContext = selectedContext?.filter(
|
||||
(c) => c.type !== element.type || c.title !== element.title
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('relative w-full scroll-pb-2', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={instructions}
|
||||
use:autosize
|
||||
oninput={handleAppInput}
|
||||
onblur={() => {
|
||||
setTimeout(() => {
|
||||
showAppContextTooltip = false
|
||||
}, 200)
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
if (showAppContextTooltip) {
|
||||
// avoid new line after Enter in the tooltip
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={modePlaceholder}
|
||||
class="resize-none"
|
||||
{disabled}
|
||||
></textarea>
|
||||
</div>
|
||||
{#if showAppContextTooltip}
|
||||
<Portal target="body">
|
||||
<div
|
||||
bind:this={appTooltipElement}
|
||||
class="absolute bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg"
|
||||
style="left: {appTooltipPosition.x}px; top: {appTooltipPosition.y}px; z-index: {zIndexes.tooltip};"
|
||||
>
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
onSelect={(element) => {
|
||||
handleAppContextSelection(element)
|
||||
}}
|
||||
showAllAvailable={true}
|
||||
stringSearch={appContextTooltipWord.slice(1)}
|
||||
onViewChange={(newNumber) => {
|
||||
appTooltipCurrentViewNumber = newNumber
|
||||
}}
|
||||
setShowing={(showing) => {
|
||||
showAppContextTooltip = showing
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
|
||||
<textarea
|
||||
|
||||
@@ -45,7 +45,12 @@ import { untrack } from 'svelte'
|
||||
import { type DBSchemas } from '$lib/stores'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
|
||||
import type { ContextElement } from './context'
|
||||
import type {
|
||||
ContextElement,
|
||||
AppFrontendFileElement,
|
||||
AppBackendRunnableElement,
|
||||
AppDatatableElement
|
||||
} from './context'
|
||||
import type { Selection } from 'monaco-editor'
|
||||
import type AIChatInput from './AIChatInput.svelte'
|
||||
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
|
||||
@@ -110,6 +115,8 @@ class AIChatManager {
|
||||
pendingNewCode = $state<string | undefined>(undefined)
|
||||
apiTools = $state<Tool<any>[]>([])
|
||||
aiChatInput = $state<AIChatInput | null>(null)
|
||||
/** Cached datatables for app context (fetched asynchronously) */
|
||||
cachedDatatables = $state<AppDatatableElement[]>([])
|
||||
|
||||
private confirmationCallback = $state<((value: boolean) => void) | undefined>(undefined)
|
||||
|
||||
@@ -713,7 +720,8 @@ class AIChatManager {
|
||||
case AIMode.APP:
|
||||
userMessage = prepareAppUserMessage(
|
||||
oldInstructions,
|
||||
this.appAiChatHelpers?.getSelectedContext()
|
||||
this.appAiChatHelpers?.getSelectedContext(),
|
||||
oldSelectedContext
|
||||
)
|
||||
break
|
||||
}
|
||||
@@ -1089,11 +1097,109 @@ class AIChatManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh cached datatables from the app helpers (async)
|
||||
* Creates one context element per table (not per datatable)
|
||||
*/
|
||||
refreshDatatables = async (): Promise<void> => {
|
||||
if (!this.appAiChatHelpers) {
|
||||
this.cachedDatatables = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const datatables = await this.appAiChatHelpers.getDatatables()
|
||||
console.log('Refreshed datatables:', datatables)
|
||||
|
||||
// Flatten to individual tables
|
||||
const tableElements: AppDatatableElement[] = []
|
||||
for (const dt of datatables) {
|
||||
if (dt.error) {
|
||||
// Skip datatables with errors
|
||||
continue
|
||||
}
|
||||
for (const [schemaName, tables] of Object.entries(dt.schemas)) {
|
||||
for (const [tableName, columns] of Object.entries(tables)) {
|
||||
// Format title as "datatable/schema:table" or "datatable/table" if schema is public
|
||||
const title =
|
||||
schemaName === 'public'
|
||||
? `${dt.datatable_name}/${tableName}`
|
||||
: `${dt.datatable_name}/${schemaName}:${tableName}`
|
||||
tableElements.push({
|
||||
type: 'app_datatable',
|
||||
datatableName: dt.datatable_name,
|
||||
schemaName,
|
||||
tableName,
|
||||
title,
|
||||
columns
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
this.cachedDatatables = tableElements
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh datatables:', err)
|
||||
this.cachedDatatables = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available context elements for app mode (frontend files + backend runnables + datatables)
|
||||
*/
|
||||
getAppAvailableContext = (): ContextElement[] => {
|
||||
if (!this.appAiChatHelpers) {
|
||||
return []
|
||||
}
|
||||
|
||||
const context: ContextElement[] = []
|
||||
|
||||
// Add frontend files
|
||||
const frontendFiles = this.appAiChatHelpers.listFrontendFiles()
|
||||
for (const path of frontendFiles) {
|
||||
const content = this.appAiChatHelpers.getFrontendFile(path)
|
||||
if (content !== undefined) {
|
||||
const element: AppFrontendFileElement = {
|
||||
type: 'app_frontend_file',
|
||||
path,
|
||||
title: path,
|
||||
content
|
||||
}
|
||||
context.push(element)
|
||||
}
|
||||
}
|
||||
|
||||
// Add backend runnables
|
||||
const runnables = this.appAiChatHelpers.listBackendRunnables()
|
||||
for (const { key } of runnables) {
|
||||
const runnable = this.appAiChatHelpers.getBackendRunnable(key)
|
||||
if (runnable) {
|
||||
const element: AppBackendRunnableElement = {
|
||||
type: 'app_backend_runnable',
|
||||
key,
|
||||
title: key,
|
||||
runnable
|
||||
}
|
||||
context.push(element)
|
||||
}
|
||||
}
|
||||
|
||||
// Add cached datatables
|
||||
context.push(...this.cachedDatatables)
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
setAppHelpers = (appHelpers: AppAIChatHelpers) => {
|
||||
this.appAiChatHelpers = appHelpers
|
||||
// Refresh datatables when app helpers are set (deferred to avoid loop)
|
||||
// Use setTimeout to ensure this runs after the effect completes
|
||||
setTimeout(() => {
|
||||
this.refreshDatatables()
|
||||
}, 50)
|
||||
|
||||
return () => {
|
||||
this.appAiChatHelpers = undefined
|
||||
this.cachedDatatables = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { ContextIconMap, type ContextElement } from './context'
|
||||
import { ArrowLeft, ChevronRight, FileCode, Code2, Table2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
selectedContext: ContextElement[]
|
||||
onSelect: (element: ContextElement) => void
|
||||
setShowing?: (showing: boolean) => void
|
||||
showAllAvailable?: boolean
|
||||
stringSearch?: string
|
||||
onViewChange?: (newNumber: number) => void
|
||||
}
|
||||
|
||||
const {
|
||||
availableContext,
|
||||
selectedContext,
|
||||
onSelect,
|
||||
setShowing,
|
||||
showAllAvailable = false,
|
||||
stringSearch = '',
|
||||
onViewChange
|
||||
}: Props = $props()
|
||||
|
||||
// Current view state: 'categories' or specific category type
|
||||
let currentView = $state<'categories' | 'files' | 'runnables' | 'datatables'>('categories')
|
||||
|
||||
// Selected index for keyboard navigation
|
||||
let itemSelectedIndex = $state(0)
|
||||
let categorySelectedIndex = $state(0)
|
||||
|
||||
// Category definitions for app mode
|
||||
const categories = [
|
||||
{ id: 'files', label: 'Frontend Files', icon: FileCode },
|
||||
{ id: 'runnables', label: 'Backend Runnables', icon: Code2 },
|
||||
{ id: 'datatables', label: 'Datatables', icon: Table2 }
|
||||
]
|
||||
|
||||
const filteredAvailableContext = $derived(
|
||||
availableContext.filter((context) => {
|
||||
const filtered =
|
||||
(showAllAvailable ||
|
||||
!selectedContext.some((sc) => sc.type === context.type && sc.title === context.title)) &&
|
||||
(!stringSearch || context.title.toLowerCase().includes(stringSearch.toLowerCase()))
|
||||
|
||||
return filtered
|
||||
})
|
||||
)
|
||||
|
||||
// Group context by category
|
||||
const contextByCategory = $derived.by(() => {
|
||||
const grouped: Record<string, ContextElement[]> = {
|
||||
files: [],
|
||||
runnables: [],
|
||||
datatables: []
|
||||
}
|
||||
|
||||
filteredAvailableContext.forEach((context) => {
|
||||
if (context.type === 'app_frontend_file') grouped.files.push(context)
|
||||
else if (context.type === 'app_backend_runnable') grouped.runnables.push(context)
|
||||
else if (context.type === 'app_datatable') grouped.datatables.push(context)
|
||||
})
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
const currentCategoryItems = $derived(
|
||||
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
|
||||
itemSelectedIndex = 0
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
function getDisplayTitle(element: ContextElement): string {
|
||||
if (element.type === 'app_frontend_file') {
|
||||
return element.path
|
||||
} else if (element.type === 'app_backend_runnable') {
|
||||
return element.key
|
||||
} else if (element.type === 'app_datatable') {
|
||||
// Show as datatable/table or datatable/schema:table
|
||||
return element.title
|
||||
}
|
||||
return element.title
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-1 text-primary text-xs p-1 pr-0 min-w-24 max-h-48 overflow-y-scroll"
|
||||
onmousedown={(e) =>
|
||||
// avoids triggering onblur on the textinput and closing the tooltip
|
||||
e.preventDefault()}
|
||||
role="listbox"
|
||||
tabindex={0}
|
||||
>
|
||||
{#if stringSearch.length > 0}
|
||||
<!-- Search view - show flat list -->
|
||||
{#each filteredAvailableContext as element, i (element.type + '-' + element.title)}
|
||||
{@const Icon = ContextIconMap[element.type]}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
itemSelectedIndex
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
onclick={() => {
|
||||
onSelect(element)
|
||||
}}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span class="truncate">
|
||||
{getDisplayTitle(element)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredAvailableContext.length === 0}
|
||||
<div class="text-center text-primary text-xs py-2">No matching context</div>
|
||||
{/if}
|
||||
{:else if currentView === 'categories'}
|
||||
<!-- Categories view -->
|
||||
{#each availableCategories as category, i (category.id)}
|
||||
{@const Icon = category.icon}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 pr-0 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
categorySelectedIndex
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
onclick={() => handleCategoryClick(category.id)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span class="flex-1">{category.label}</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
{/each}
|
||||
{#if availableCategories.length === 0}
|
||||
<div class="text-center text-primary text-xs py-2">No available context</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Category items view -->
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md text-left flex flex-row gap-1 items-center font-normal transition-colors mb-1"
|
||||
onclick={handleBackClick}
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
<span class="text-xs">Go back</span>
|
||||
</button>
|
||||
|
||||
{#if currentCategoryItems.length === 0}
|
||||
<div class="text-center text-primary text-xs py-2">No items in this category</div>
|
||||
{:else}
|
||||
{#each currentCategoryItems as element, i (element.type + '-' + element.title)}
|
||||
{@const Icon = ContextIconMap[element.type]}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
itemSelectedIndex
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
onclick={() => {
|
||||
onSelect(element)
|
||||
currentView = 'categories' // Go back to categories after selection
|
||||
}}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon size={16} />
|
||||
{/if}
|
||||
<span class="truncate">
|
||||
{getDisplayTitle(element)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -100,6 +100,50 @@
|
||||
<div class="text-primary">{contextElement.title}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if contextElement.type === 'app_frontend_file'}
|
||||
<div class="max-w-96 max-h-[300px] text-xs overflow-auto">
|
||||
<HighlightCode
|
||||
language={contextElement.path.endsWith('.tsx') || contextElement.path.endsWith('.ts')
|
||||
? 'bun'
|
||||
: contextElement.path.endsWith('.css')
|
||||
? 'bash'
|
||||
: 'bun'}
|
||||
code={contextElement.content}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
</div>
|
||||
{:else if contextElement.type === 'app_backend_runnable'}
|
||||
<div class="p-2 max-w-96 max-h-[300px] text-xs overflow-auto">
|
||||
{#if contextElement.runnable.inlineScript}
|
||||
<HighlightCode
|
||||
language={contextElement.runnable.inlineScript.language}
|
||||
code={contextElement.runnable.inlineScript.content}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
{:else}
|
||||
<ObjectViewer json={contextElement.runnable} pureViewer collapseLevel={2} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if contextElement.type === 'app_code_selection'}
|
||||
<div class="max-w-96 max-h-[300px] text-xs overflow-auto">
|
||||
<div class="text-tertiary text-xs mb-1 px-2 pt-1">
|
||||
{contextElement.source} (L{contextElement.startLine}-L{contextElement.endLine})
|
||||
</div>
|
||||
<HighlightCode
|
||||
language="bun"
|
||||
code={contextElement.content}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
</div>
|
||||
{:else if contextElement.type === 'app_datatable'}
|
||||
<div class="p-2 max-w-96 max-h-[300px] text-xs overflow-auto">
|
||||
<div class="text-tertiary text-xs mb-1">
|
||||
{contextElement.datatableName}/{contextElement.schemaName === 'public'
|
||||
? ''
|
||||
: contextElement.schemaName + ':'}{contextElement.tableName}
|
||||
</div>
|
||||
<ObjectViewer json={contextElement.columns} pureViewer collapseLevel={1} />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
|
||||
@@ -124,6 +124,14 @@ export function createAppEvalHelpers(
|
||||
) => {
|
||||
// Return success with empty result for eval testing
|
||||
return { success: true, result: [] }
|
||||
},
|
||||
|
||||
addTableToWhitelist: (
|
||||
_datatableName: string,
|
||||
_schemaName: string,
|
||||
_tableName: string
|
||||
) => {
|
||||
// No-op for eval testing - tables are not tracked in test context
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ import { FlowService, ScriptService, type Flow, type Script } from '$lib/gen'
|
||||
import uFuzzy from '@leeoniya/ufuzzy'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { aiChatManager } from '../AIChatManager.svelte'
|
||||
import type {
|
||||
ContextElement,
|
||||
AppFrontendFileElement,
|
||||
AppBackendRunnableElement,
|
||||
AppCodeSelectionElement,
|
||||
AppDatatableElement
|
||||
} from '../context'
|
||||
|
||||
// Backend runnable types
|
||||
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline'
|
||||
@@ -93,8 +100,10 @@ export interface SelectedContext {
|
||||
clearInspector?: () => void
|
||||
/** Function to clear the runnable selection (go back to frontend view) */
|
||||
clearRunnable?: () => void
|
||||
// Future: text selection within the file
|
||||
// textSelection?: { startLine: number; endLine: number; startColumn: number; endColumn: number }
|
||||
/** Code selection from the editor (either frontend or backend) */
|
||||
codeSelection?: AppCodeSelectionElement
|
||||
/** Function to clear the code selection */
|
||||
clearCodeSelection?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +151,8 @@ export interface AppAIChatHelpers {
|
||||
sql: string,
|
||||
newTable?: { schema: string; name: string }
|
||||
) => Promise<{ success: boolean; result?: Record<string, any>[]; error?: string }>
|
||||
/** Add a table to the app's whitelisted tables (called when user selects a table via @) */
|
||||
addTableToWhitelist: (datatableName: string, schemaName: string, tableName: string) => void
|
||||
}
|
||||
|
||||
// ============= Utility =============
|
||||
@@ -1085,15 +1096,22 @@ const MAX_CONTEXT_CONTENT_LENGTH = 3000
|
||||
|
||||
export function prepareAppUserMessage(
|
||||
instructions: string,
|
||||
selectedContext?: SelectedContext
|
||||
selectedContext?: SelectedContext,
|
||||
additionalContext?: ContextElement[]
|
||||
): ChatCompletionUserMessageParam {
|
||||
let content = ''
|
||||
|
||||
if (selectedContext && (selectedContext.type !== 'none' || selectedContext.inspectorElement)) {
|
||||
// Check if we have any context to add
|
||||
const hasSelectedContext =
|
||||
selectedContext && (selectedContext.type !== 'none' || selectedContext.inspectorElement)
|
||||
const hasAdditionalContext = additionalContext && additionalContext.length > 0
|
||||
|
||||
if (hasSelectedContext || hasAdditionalContext) {
|
||||
content += `## SELECTED CONTEXT:\n`
|
||||
|
||||
// Add frontend file context with content (unless excluded)
|
||||
if (
|
||||
selectedContext &&
|
||||
selectedContext.type === 'frontend' &&
|
||||
selectedContext.frontendPath &&
|
||||
!selectedContext.selectionExcluded
|
||||
@@ -1111,6 +1129,7 @@ export function prepareAppUserMessage(
|
||||
|
||||
// Add backend runnable context with content (unless excluded)
|
||||
if (
|
||||
selectedContext &&
|
||||
selectedContext.type === 'backend' &&
|
||||
selectedContext.backendKey &&
|
||||
!selectedContext.selectionExcluded
|
||||
@@ -1139,7 +1158,7 @@ export function prepareAppUserMessage(
|
||||
}
|
||||
|
||||
// Add inspector element context if available
|
||||
if (selectedContext.inspectorElement) {
|
||||
if (selectedContext?.inspectorElement) {
|
||||
const el = selectedContext.inspectorElement
|
||||
content += `\nThe user has selected an element in the app preview using the inspector tool:\n`
|
||||
content += `- **Element**: ${el.tagName}${el.id ? `#${el.id}` : ''}${el.className ? `.${el.className.split(' ').join('.')}` : ''}\n`
|
||||
@@ -1154,6 +1173,76 @@ export function prepareAppUserMessage(
|
||||
const truncatedHtml = el.html.length > 500 ? el.html.slice(0, 500) + '...' : el.html
|
||||
content += `- **HTML**:\n\`\`\`html\n${truncatedHtml}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
// Add code selection context if available
|
||||
if (selectedContext?.codeSelection) {
|
||||
const selection = selectedContext.codeSelection
|
||||
content += `\n### CODE SELECTION:\n`
|
||||
content += `The user has selected code in the ${selection.sourceType} editor:\n`
|
||||
content += `- **File/Source**: ${selection.source}\n`
|
||||
content += `- **Lines**: ${selection.startLine}-${selection.endLine}\n`
|
||||
const truncatedCode =
|
||||
selection.content.length > MAX_CONTEXT_CONTENT_LENGTH
|
||||
? selection.content.slice(0, MAX_CONTEXT_CONTENT_LENGTH) + '\n... [TRUNCATED]'
|
||||
: selection.content
|
||||
content += `\`\`\`\n${truncatedCode}\n\`\`\`\n`
|
||||
}
|
||||
|
||||
// Add additional context from @ mentions
|
||||
if (additionalContext && additionalContext.length > 0) {
|
||||
content += `\n### ADDITIONAL CONTEXT (mentioned by user):\n`
|
||||
|
||||
for (const ctx of additionalContext) {
|
||||
if (ctx.type === 'app_frontend_file') {
|
||||
const fileCtx = ctx as AppFrontendFileElement
|
||||
content += `\n**Frontend File: ${fileCtx.path}**\n`
|
||||
const truncatedContent =
|
||||
fileCtx.content.length > MAX_CONTEXT_CONTENT_LENGTH
|
||||
? fileCtx.content.slice(0, MAX_CONTEXT_CONTENT_LENGTH) + '\n... [TRUNCATED]'
|
||||
: fileCtx.content
|
||||
content += `\`\`\`\n${truncatedContent}\n\`\`\`\n`
|
||||
} else if (ctx.type === 'app_backend_runnable') {
|
||||
const runnableCtx = ctx as AppBackendRunnableElement
|
||||
const runnable = runnableCtx.runnable
|
||||
content += `\n**Backend Runnable: ${runnableCtx.key}**\n`
|
||||
content += `- **Name**: ${runnable.name}\n`
|
||||
content += `- **Type**: ${runnable.type}\n`
|
||||
if (runnable.path) {
|
||||
content += `- **Path**: ${runnable.path}\n`
|
||||
}
|
||||
if (runnable.inlineScript) {
|
||||
const truncatedCode =
|
||||
runnable.inlineScript.content.length > MAX_CONTEXT_CONTENT_LENGTH
|
||||
? runnable.inlineScript.content.slice(0, MAX_CONTEXT_CONTENT_LENGTH) +
|
||||
'\n... [TRUNCATED]'
|
||||
: runnable.inlineScript.content
|
||||
content += `- **Language**: ${runnable.inlineScript.language}\n`
|
||||
content += `- **Code**:\n\`\`\`${runnable.inlineScript.language === 'bun' ? 'typescript' : 'python'}\n${truncatedCode}\n\`\`\`\n`
|
||||
}
|
||||
if (runnable.staticInputs && Object.keys(runnable.staticInputs).length > 0) {
|
||||
content += `- **Static inputs**: ${JSON.stringify(runnable.staticInputs)}\n`
|
||||
}
|
||||
} else if (ctx.type === 'app_datatable') {
|
||||
const datatableCtx = ctx as AppDatatableElement
|
||||
const tableRef =
|
||||
datatableCtx.schemaName === 'public'
|
||||
? `${datatableCtx.datatableName}/${datatableCtx.tableName}`
|
||||
: `${datatableCtx.datatableName}/${datatableCtx.schemaName}:${datatableCtx.tableName}`
|
||||
content += `\n**Table: ${tableRef}**\n`
|
||||
content += `- **Datatable**: ${datatableCtx.datatableName}\n`
|
||||
content += `- **Schema**: ${datatableCtx.schemaName}\n`
|
||||
content += `- **Table**: ${datatableCtx.tableName}\n`
|
||||
// Format columns as column_name: type
|
||||
const columnsStr = JSON.stringify(datatableCtx.columns, null, 2)
|
||||
const truncatedColumns =
|
||||
columnsStr.length > MAX_CONTEXT_CONTENT_LENGTH
|
||||
? columnsStr.slice(0, MAX_CONTEXT_CONTENT_LENGTH) + '\n... [TRUNCATED]'
|
||||
: columnsStr
|
||||
content += `- **Columns** (column_name -> type):\n\`\`\`json\n${truncatedColumns}\n\`\`\`\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content += '\n'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Code, Database, TriangleAlert, Diff } from 'lucide-svelte'
|
||||
import { Code, Database, TriangleAlert, Diff, FileCode, Code2, TextSelect, Table2 } from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen/types.gen'
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { type Change } from 'diff'
|
||||
import type { BackendRunnable } from './app/core'
|
||||
|
||||
export const ContextIconMap = {
|
||||
code: Code,
|
||||
error: TriangleAlert,
|
||||
db: Database,
|
||||
diff: Diff,
|
||||
code_piece: Code
|
||||
code_piece: Code,
|
||||
app_frontend_file: FileCode,
|
||||
app_backend_runnable: Code2,
|
||||
app_code_selection: TextSelect,
|
||||
app_datatable: Table2
|
||||
// flow_module type is handled with FlowModuleIcon
|
||||
}
|
||||
|
||||
@@ -67,6 +72,62 @@ export interface FlowModuleCodePieceElement extends Omit<CodePieceElement, 'type
|
||||
value: FlowModuleElement['value']
|
||||
}
|
||||
|
||||
/** App frontend file context element */
|
||||
export interface AppFrontendFileElement {
|
||||
type: 'app_frontend_file'
|
||||
/** The file path (e.g., /index.tsx, /styles.css) */
|
||||
path: string
|
||||
/** Title for display (the path) */
|
||||
title: string
|
||||
/** The file content */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** App backend runnable context element */
|
||||
export interface AppBackendRunnableElement {
|
||||
type: 'app_backend_runnable'
|
||||
/** The runnable key */
|
||||
key: string
|
||||
/** Title for display (the key) */
|
||||
title: string
|
||||
/** The runnable configuration */
|
||||
runnable: BackendRunnable
|
||||
}
|
||||
|
||||
/** App code selection context element (from frontend or backend editor) */
|
||||
export interface AppCodeSelectionElement {
|
||||
type: 'app_code_selection'
|
||||
/** Source: frontend file path or backend runnable key */
|
||||
source: string
|
||||
/** Whether this is from frontend or backend */
|
||||
sourceType: 'frontend' | 'backend'
|
||||
/** Title for display */
|
||||
title: string
|
||||
/** The selected code content */
|
||||
content: string
|
||||
/** Line range (1-indexed) */
|
||||
startLine: number
|
||||
endLine: number
|
||||
/** Column range (1-indexed) */
|
||||
startColumn: number
|
||||
endColumn: number
|
||||
}
|
||||
|
||||
/** App datatable table context element (represents a single table within a datatable) */
|
||||
export interface AppDatatableElement {
|
||||
type: 'app_datatable'
|
||||
/** The datatable name (e.g., "main") */
|
||||
datatableName: string
|
||||
/** The schema name (e.g., "public") */
|
||||
schemaName: string
|
||||
/** The table name (e.g., "users") */
|
||||
tableName: string
|
||||
/** Title for display (e.g., "main/public:users" or "main/users") */
|
||||
title: string
|
||||
/** The table columns: column_name -> compact_type */
|
||||
columns: Record<string, string>
|
||||
}
|
||||
|
||||
export type ContextElement = (
|
||||
| CodeElement
|
||||
| ErrorElement
|
||||
@@ -75,6 +136,10 @@ export type ContextElement = (
|
||||
| CodePieceElement
|
||||
| FlowModuleElement
|
||||
| FlowModuleCodePieceElement
|
||||
| AppFrontendFileElement
|
||||
| AppBackendRunnableElement
|
||||
| AppCodeSelectionElement
|
||||
| AppDatatableElement
|
||||
) & {
|
||||
deletable?: boolean
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import type { LintResult, DataTableSchema, InspectorElementInfo } from '../copilot/chat/app/core'
|
||||
import type { AppCodeSelectionElement } from '../copilot/chat/context'
|
||||
import { rawAppLintStore } from './lintStore'
|
||||
import { dbSchemas } from '$lib/stores'
|
||||
import { runScriptAndPollResult } from '../jobs/utils'
|
||||
@@ -392,7 +393,11 @@
|
||||
selectionExcluded: selectionExcludedFromPrompt,
|
||||
toggleSelectionExcluded: toggleSelectionExcluded,
|
||||
clearInspector: clearInspectorSelection,
|
||||
clearRunnable: handleClearRunnable
|
||||
clearRunnable: handleClearRunnable,
|
||||
codeSelection: codeSelection,
|
||||
clearCodeSelection: () => {
|
||||
codeSelection = undefined
|
||||
}
|
||||
}
|
||||
if (selectedRunnable) {
|
||||
const runnable = convertToBackendRunnable(selectedRunnable, runnables[selectedRunnable])
|
||||
@@ -440,6 +445,12 @@
|
||||
// Get unique datatable names from dataTableRefs (the whitelisted tables)
|
||||
const whitelistedDatatables = new Set(dataTableRefsObjects.map((ref) => ref.datatable))
|
||||
|
||||
// If no datatables are configured, return all available datatables
|
||||
// This allows users to see all datatables in the @ context menu
|
||||
if (whitelistedDatatables.size === 0) {
|
||||
return allSchemas
|
||||
}
|
||||
|
||||
// Build a map of whitelisted tables per datatable: datatable -> schema -> Set<table>
|
||||
const whitelistedTables = new Map<string, Map<string, Set<string>>>()
|
||||
for (const ref of dataTableRefsObjects) {
|
||||
@@ -550,6 +561,19 @@
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
},
|
||||
addTableToWhitelist: (datatableName: string, schemaName: string, tableName: string) => {
|
||||
// Format the table reference
|
||||
const newRef = formatDataTableRef({
|
||||
datatable: datatableName,
|
||||
schema: schemaName === 'public' ? undefined : schemaName,
|
||||
table: tableName
|
||||
})
|
||||
// Only add if not already present
|
||||
if (!data.tables.includes(newRef)) {
|
||||
data.tables = [...data.tables, newRef]
|
||||
saveFrontendDraft()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -557,6 +581,7 @@
|
||||
let selectedDocument: string | undefined = $state(undefined)
|
||||
let inspectorElement: InspectorElementInfo | undefined = $state(undefined)
|
||||
let selectionExcludedFromPrompt: boolean = $state(false)
|
||||
let codeSelection: AppCodeSelectionElement | undefined = $state(undefined)
|
||||
|
||||
function toggleSelectionExcluded() {
|
||||
selectionExcludedFromPrompt = !selectionExcludedFromPrompt
|
||||
@@ -596,6 +621,27 @@
|
||||
} else if (e.data.type === 'inspectorClear') {
|
||||
// Clear the inspector element when user dismisses the selection
|
||||
inspectorElement = undefined
|
||||
} else if (e.data.type === 'editorSelection') {
|
||||
// Handle code selection from the iframe editor
|
||||
const selection = e.data.selection
|
||||
if (selection === null) {
|
||||
// Selection cleared
|
||||
codeSelection = undefined
|
||||
} else {
|
||||
// Normalize path
|
||||
const normalizedPath = selection.path?.replace(/\\/g, '/')
|
||||
codeSelection = {
|
||||
type: 'app_code_selection',
|
||||
source: normalizedPath,
|
||||
sourceType: 'frontend',
|
||||
title: `${normalizedPath}:L${selection.range.startLine}-L${selection.range.endLine}`,
|
||||
content: selection.content,
|
||||
startLine: selection.range.startLine,
|
||||
endLine: selection.range.endLine,
|
||||
startColumn: selection.range.startColumn,
|
||||
endColumn: selection.range.endColumn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user